diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c50c1225..1a6c9ff7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,6 @@ on: - "src-tauri/**" - "plugins/**" - "index.html" - - "splashscreen.html" - "vite.config.ts" - "build.cjs" - "package.json" @@ -23,7 +22,6 @@ on: - "src-tauri/**" - "plugins/**" - "index.html" - - "splashscreen.html" - "vite.config.ts" - "build.cjs" - "package.json" @@ -66,4 +64,3 @@ jobs: - name: Rust check working-directory: src-tauri run: cargo check - diff --git a/.github/workflows/tauri-build-win-official.yml b/.github/workflows/tauri-build-win-official.yml deleted file mode 100644 index bb60f810..00000000 --- a/.github/workflows/tauri-build-win-official.yml +++ /dev/null @@ -1,252 +0,0 @@ -name: tauri-build-windows-official - -on: - workflow_dispatch: - inputs: - env_name: - description: "Frontend build mode (development/production/test)" - required: false - default: "production" - -jobs: - build: - # Windows 专用构建 Job: - # - 固定运行在 GitHub-hosted Windows Server 2025 Runner 上 - # - 不适用于 macOS / Linux 等其他平台 - # 如需支持多平台,请为不同平台创建独立的 workflow 或 job - runs-on: windows-2025 - permissions: - contents: write - - env: - # 前端构建参数 - # build.cjs reads ENV_NAME; default production - ENV_NAME: ${{ inputs.env_name || 'production' }} - - # App config values - APP_SERVER_BASE_URL: ${{ secrets.APP_SERVER_BASE_URL }} - APP_SERVER_VERSION: ${{ secrets.APP_SERVER_VERSION }} - APP_SERVER_SECRET_KEY: ${{ secrets.APP_SERVER_SECRET_KEY }} - APP_UPDATER_CHECK_URL: ${{ secrets.APP_UPDATER_CHECK_URL }} - APP_UPDATER_LATEST_JSON_URL: ${{ secrets.APP_UPDATER_LATEST_JSON_URL }} - APP_UPDATER_TEMP_DIR: ${{ secrets.APP_UPDATER_TEMP_DIR }} - APP_WEBVIEW_DOWNLOAD_URL: ${{ secrets.APP_WEBVIEW_DOWNLOAD_URL }} - - # Release 相关上下文 - # - tag push 时 github.ref_name 即为 vX.Y.Z - # - workflow_dispatch 时不会创建 release,仅用于手动构建 - RELEASE_TAG: ${{ github.ref_name }} - # R2 相关配置(若未配置对应 Secret,则相关功能会被跳过或报错) - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_BUCKET: ${{ secrets.R2_BUCKET }} - R2_PUBLIC_BASE_URL: ${{ secrets.R2_PUBLIC_BASE_URL }} - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Ensure AWS CLI - shell: powershell - run: | - $ErrorActionPreference = "Stop" - - if (Get-Command aws -ErrorAction SilentlyContinue) { - aws --version - exit 0 - } - - $msiPath = Join-Path $env:RUNNER_TEMP "AWSCLIV2.msi" - Invoke-WebRequest "https://awscli.amazonaws.com/AWSCLIV2.msi" -OutFile $msiPath - Start-Process msiexec.exe -Wait -ArgumentList "/i `"$msiPath`" /qn" - - @( - "C:\Program Files\Amazon\AWSCLIV2", - "C:\Program Files\Amazon\AWSCLIV2\bin" - ) | ForEach-Object { - if (Test-Path $_) { - Add-Content -Path $env:GITHUB_PATH -Value $_ - } - } - - $awsExe = Get-Command aws -ErrorAction SilentlyContinue - if (-not $awsExe) { - $awsInstalled = Get-ChildItem "C:\Program Files\Amazon" -Filter aws.exe -Recurse -ErrorAction SilentlyContinue | - Select-Object -First 1 - if (-not $awsInstalled) { - Write-Error "AWS CLI installation completed, but aws.exe was not found." - } - & $awsInstalled.FullName --version - exit 0 - } - - aws --version - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Prepare app config - shell: powershell - run: | - Copy-Item "src-tauri/config.example.toml" "src-tauri/config.production.toml" -Force - - $configPath = "src-tauri/config.production.toml" - $content = Get-Content $configPath -Raw - - function Set-ConfigValue { - param( - [string]$Pattern, - [string]$Value - ) - - if ([string]::IsNullOrWhiteSpace($Value)) { - return - } - - $escapedValue = $Value.Replace('\', '\\').Replace('"', '\"') - $script:content = [regex]::Replace( - $script:content, - $Pattern, - ('$1"{0}"' -f $escapedValue), - [System.Text.RegularExpressions.RegexOptions]::Multiline - ) - } - - Set-ConfigValue '^(base_url\s*=\s*)".*"$' "${{ env.APP_SERVER_BASE_URL }}" - Set-ConfigValue '^(version\s*=\s*)".*"$' "${{ env.APP_SERVER_VERSION }}" - Set-ConfigValue '^(secret_key\s*=\s*)".*"$' "${{ env.APP_SERVER_SECRET_KEY }}" - Set-ConfigValue '^(check_url\s*=\s*)".*"$' "${{ env.APP_UPDATER_CHECK_URL }}" - Set-ConfigValue '^(latest_json_url\s*=\s*)".*"$' "${{ env.APP_UPDATER_LATEST_JSON_URL }}" - Set-ConfigValue '^(updater_temp_dir\s*=\s*)".*"$' "${{ env.APP_UPDATER_TEMP_DIR }}" - Set-ConfigValue '^(downlaod_url\s*=\s*)".*"$' "${{ env.APP_WEBVIEW_DOWNLOAD_URL }}" - - $utf8NoBom = New-Object System.Text.UTF8Encoding($false) - $resolvedConfigPath = (Resolve-Path $configPath).Path - [System.IO.File]::WriteAllText($resolvedConfigPath, $content, $utf8NoBom) - - # 在 CI 构建期间使用 fixed 版本的 Tauri 配置 - # 先将 src-tauri/tauri.conf.fixed.json 覆盖为 src-tauri/tauri.conf.json, - # 然后再由 prepare-version.mjs 统一写入最终版本号,确保参与打包的配置版本正确。 - - name: Use fixed Tauri config - shell: powershell - run: | - Copy-Item "src-tauri/tauri.conf.fixed.json" "src-tauri/tauri.conf.json" -Force - - - name: Prepare version from tag - # For release events, GITHUB_REF_NAME is usually "vX.Y.Z" as well. - run: node deploy/prepare-version.mjs - - # Build and bundle the Tauri app. - # It will run `beforeBuildCommand` from `src-tauri/tauri.conf.json` (node build.cjs -> pnpm build:) - - name: Build Tauri app - uses: tauri-apps/tauri-action@v0 - env: - # 优先使用 GT_TOKEN;未配置时回退到 GitHub 自动注入的 token - GITHUB_TOKEN: ${{ secrets.GT_TOKEN || github.token }} - # Optional: enable updater signing / release signing if you configure them later - TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} - TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} - with: - projectPath: . - # 与本地一致,使用 production feature 进行构建 - args: --features production - - - name: Generate latest.json - run: node deploy/generate-latest-json.mjs - - - name: Publish GitHub release assets - if: startsWith(github.ref, 'refs/tags/') - env: - GH_TOKEN: ${{ secrets.GT_TOKEN || github.token }} - GH_REPO: ${{ github.repository }} - run: node deploy/publish-github-release.mjs - - - name: Upload installer to R2 storage - # tag push 时上传到 R2 - if: startsWith(github.ref, 'refs/tags/') - shell: powershell - run: | - $ErrorActionPreference = "Stop" - - $TAG = "${{ env.RELEASE_TAG }}" - # 去掉前缀 v,得到纯版本号,如 0.1.0 - $VERSION = $TAG.TrimStart('v') - - # 找到 NSIS 安装包 - $installer = Get-ChildItem "src-tauri/target/release/bundle/nsis" -Filter *.exe | Select-Object -First 1 - if (-not $installer) { - Write-Error "NSIS installer not found under src-tauri/target/release/bundle/nsis" - } - - # 使用 AWS CLI 上传到 R2(唯一方式) - if (-not (Get-Command aws -ErrorAction SilentlyContinue)) { - Write-Error "aws CLI not found. Please install AWS CLI v2 and ensure 'aws' is in PATH." - } - - $accountId = "${{ env.R2_ACCOUNT_ID }}" - $bucket = "${{ env.R2_BUCKET }}" - $endpoint = "https://${accountId}.r2.cloudflarestorage.com" - $dest = "s3://${bucket}/${VERSION}/simprint_setup.exe" - - $env:AWS_ACCESS_KEY_ID = "${{ env.R2_ACCESS_KEY_ID }}" - $env:AWS_SECRET_ACCESS_KEY = "${{ env.R2_SECRET_ACCESS_KEY }}" - $env:AWS_EC2_METADATA_DISABLED = "true" - - Write-Host "Uploading $($installer.FullName) to R2: $dest" - aws s3 cp $installer.FullName $dest --endpoint-url $endpoint --region auto - - - name: Upload latest.json to R2 root - # tag push 时上传到 R2 - if: startsWith(github.ref, 'refs/tags/') - shell: powershell - run: | - $ErrorActionPreference = "Stop" - - if (-not (Get-Command aws -ErrorAction SilentlyContinue)) { - Write-Error "aws CLI not found. Please install AWS CLI v2 and ensure 'aws' is in PATH." - } - - $accountId = "${{ env.R2_ACCOUNT_ID }}" - $bucket = "${{ env.R2_BUCKET }}" - $endpoint = "https://${accountId}.r2.cloudflarestorage.com" - $dest = "s3://${bucket}/latest.json" - - $env:AWS_ACCESS_KEY_ID = "${{ env.R2_ACCESS_KEY_ID }}" - $env:AWS_SECRET_ACCESS_KEY = "${{ env.R2_SECRET_ACCESS_KEY }}" - $env:AWS_EC2_METADATA_DISABLED = "true" - - Write-Host "Uploading latest.json to R2 root: $dest" - aws s3 cp "latest.json" $dest --endpoint-url $endpoint --region auto - - - name: Upload latest.json artifact (manual runs) - if: github.event_name == 'workflow_dispatch' && hashFiles('latest.json') != '' - uses: actions/upload-artifact@v4 - with: - name: latest-json - path: latest.json - - - name: Publish version metadata - if: startsWith(github.ref, 'refs/tags/') - env: - VERSION_API_URL: ${{ secrets.VERSION_API_URL }} - VERSION_API_KEY: ${{ secrets.VERSION_API_KEY }} - RELEASE_TAG: ${{ env.RELEASE_TAG }} - run: node deploy/publish-version.mjs diff --git a/.github/workflows/tauri-release-win.yml b/.github/workflows/tauri-release-win.yml index dc001af8..da09a689 100644 --- a/.github/workflows/tauri-release-win.yml +++ b/.github/workflows/tauri-release-win.yml @@ -3,18 +3,14 @@ name: tauri-release-windows on: push: tags: - - "v*" + - 'v*' jobs: - build: + publish: runs-on: windows-2025 permissions: contents: write - env: - ENV_NAME: production - RELEASE_TAG: ${{ github.ref_name }} - steps: - name: Checkout uses: actions/checkout@v4 @@ -35,82 +31,229 @@ jobs: - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc,aarch64-pc-windows-msvc,i686-pc-windows-msvc - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Prepare app config + - name: Prepare production build shell: powershell env: - # Optional overrides. If these are not configured, the workflow - # will keep the defaults from src-tauri/config.example.toml. - APP_SERVER_BASE_URL: ${{ secrets.APP_SERVER_BASE_URL }} - APP_SERVER_VERSION: ${{ secrets.APP_SERVER_VERSION }} - APP_SERVER_SECRET_KEY: ${{ secrets.APP_SERVER_SECRET_KEY }} - APP_UPDATER_CHECK_URL: ${{ secrets.APP_UPDATER_CHECK_URL }} - APP_UPDATER_LATEST_JSON_URL: ${{ secrets.APP_UPDATER_LATEST_JSON_URL }} - APP_UPDATER_TEMP_DIR: ${{ secrets.APP_UPDATER_TEMP_DIR }} - APP_WEBVIEW_DOWNLOAD_URL: ${{ secrets.APP_WEBVIEW_DOWNLOAD_URL }} + TAURI_UPDATER_PUBLIC_KEY: ${{ secrets.TAURI_UPDATER_PUBLIC_KEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} run: | + if ([string]::IsNullOrWhiteSpace($env:TAURI_UPDATER_PUBLIC_KEY)) { + throw "TAURI_UPDATER_PUBLIC_KEY is required" + } + if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY)) { + throw "TAURI_SIGNING_PRIVATE_KEY is required" + } + Copy-Item "src-tauri/config.example.toml" "src-tauri/config.production.toml" -Force - $configPath = "src-tauri/config.production.toml" - $content = Get-Content $configPath -Raw - - function Set-ConfigValue { - param( - [string]$Pattern, - [string]$Value - ) - - if ([string]::IsNullOrWhiteSpace($Value)) { - return - } - - $escapedValue = $Value.Replace('\', '\\').Replace('"', '\"') - $script:content = [regex]::Replace( - $script:content, - $Pattern, - ('$1"{0}"' -f $escapedValue), - [System.Text.RegularExpressions.RegexOptions]::Multiline - ) + - name: Read annotated tag notes + id: release_notes + shell: powershell + run: | + $notes = git for-each-ref --format='%(contents)' "refs/tags/${{ github.ref_name }}" + if ([string]::IsNullOrWhiteSpace($notes)) { + $notes = "See the assets below to download this version." } + "body< p.toLowerCase().endsWith('.exe')); - -if (!installerPath) { - throw new Error( - `[generate-latest-json] Cannot find NSIS installer (.exe) under: ${bundleNsisDir}` - ); -} - -const installerName = path.basename(installerPath); -const signaturePath = findFirstFile(bundleNsisDir, (p) => p.toLowerCase().endsWith('.sig')); -const signature = signaturePath ? fs.readFileSync(signaturePath, 'utf8').trim() : null; - -// Prefer GitHub Releases download URL when available. -// Example: -// - RELEASE_REPO=Simprint/simprint-release -// - RELEASE_TAG=v1.2.3 -// -> https://github.com/Simprint/simprint-release/releases/download/v1.2.3/ -const releaseRepo = process.env.RELEASE_REPO || process.env.GITHUB_REPOSITORY; -const tag = process.env.RELEASE_TAG || process.env.GITHUB_REF_NAME || `v${version}`; -const baseUrl = - process.env.DOWNLOAD_BASE_URL || - (releaseRepo ? `https://github.com/${releaseRepo}/releases/download/${tag}` : null); - -if (!baseUrl) { - throw new Error( - '[generate-latest-json] Missing DOWNLOAD_BASE_URL and cannot infer from GITHUB_REPOSITORY.' - ); -} - -const url = `${baseUrl}/${encodeURIComponent(installerName)}`; - -// Optional: R2 public URL, e.g. https://r2.example.com/releases -// Final R2 URL will be: ${R2_PUBLIC_BASE_URL}/${version}/simprint_setup.exe -const r2Base = process.env.R2_PUBLIC_BASE_URL; -const r2Url = r2Base - ? `${r2Base.replace(/\/$/, '')}/${version}/simprint_setup.exe` - : null; -const pub_date = new Date().toISOString(); -const notes = process.env.RELEASE_NOTES || ''; - -// Tauri updater-style manifest (works for many clients). -// Platform key: use the Rust target triple for Windows MSVC. -const latest = { - version, - notes, - pub_date, - platforms: { - 'x86_64-pc-windows-msvc': { - url, - ...(signature ? { signature } : {}), - ...(r2Url ? { r2_url: r2Url } : {}), - }, - }, -}; - -const outPath = path.join(root, 'latest.json'); -fs.writeFileSync(outPath, JSON.stringify(latest, null, 2) + '\n', 'utf8'); - -console.log(`[generate-latest-json] Wrote ${path.relative(root, outPath)}`); -console.log(`[generate-latest-json] Installer: ${installerName}`); -console.log(`[generate-latest-json] URL: ${url}`); -console.log(`[generate-latest-json] Signature: ${signature ? 'present' : 'missing'}`); - diff --git a/deploy/prepare-tauri-config.mjs b/deploy/prepare-tauri-config.mjs new file mode 100644 index 00000000..fcd034d7 --- /dev/null +++ b/deploy/prepare-tauri-config.mjs @@ -0,0 +1,59 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const CONFIG_BY_MODE = { + embedBootstrapper: 'tauri.conf.embed-bootstrapper.json', + 'fixed-runtime': 'tauri.conf.fixed-runtime.json', +}; + +const TARGET_BY_NODE_ARCH = { + x64: 'x86_64-pc-windows-msvc', + arm64: 'aarch64-pc-windows-msvc', + ia32: 'i686-pc-windows-msvc', +}; + +const FIXED_RUNTIME_PATH_BY_TARGET = { + 'x86_64-pc-windows-msvc': + './webview-fixed/Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.x64/', + 'aarch64-pc-windows-msvc': + './webview-fixed/Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.arm64/', + 'i686-pc-windows-msvc': + './webview-fixed/Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.x86/', +}; + +const mode = process.argv[2]; +const sourceName = CONFIG_BY_MODE[mode]; +const target = process.argv[3] || TARGET_BY_NODE_ARCH[process.arch]; + +if (!sourceName) { + throw new Error( + `[prepare-tauri-config] Unsupported mode "${mode ?? ''}". ` + + 'Expected embedBootstrapper or fixed-runtime.' + ); +} + +if (!FIXED_RUNTIME_PATH_BY_TARGET[target]) { + throw new Error( + `[prepare-tauri-config] Unsupported Windows target "${target ?? ''}". ` + + 'Expected x86_64-pc-windows-msvc, aarch64-pc-windows-msvc or i686-pc-windows-msvc.' + ); +} + +const root = path.resolve(process.cwd()); +const sourcePath = path.join(root, 'src-tauri', sourceName); +const targetPath = path.join(root, 'src-tauri', 'tauri.conf.json'); +const config = JSON.parse(fs.readFileSync(sourcePath, 'utf8')); +const publicKey = process.env.TAURI_UPDATER_PUBLIC_KEY?.trim(); + +if (mode === 'fixed-runtime') { + config.bundle.windows.webviewInstallMode.path = FIXED_RUNTIME_PATH_BY_TARGET[target]; +} + +if (publicKey) { + config.plugins ??= {}; + config.plugins.updater ??= {}; + config.plugins.updater.pubkey = publicKey; +} + +fs.writeFileSync(targetPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); +console.log(`[prepare-tauri-config] Prepared ${mode} for ${target} from src-tauri/${sourceName}`); diff --git a/deploy/publish-github-release.mjs b/deploy/publish-github-release.mjs deleted file mode 100644 index 55000c0d..00000000 --- a/deploy/publish-github-release.mjs +++ /dev/null @@ -1,231 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; - -const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; -const repository = process.env.GH_REPO || process.env.GITHUB_REPOSITORY; -const tag = process.env.RELEASE_TAG; -const installerDir = path.resolve( - process.env.RELEASE_INSTALLER_DIR || 'src-tauri/target/release/bundle/nsis' -); -const latestJsonPath = path.resolve(process.env.RELEASE_LATEST_JSON_PATH || 'latest.json'); - -if (!token) throw new Error('GH_TOKEN or GITHUB_TOKEN is not set'); -if (!repository) throw new Error('GH_REPO or GITHUB_REPOSITORY is not set'); -if (!tag) throw new Error('RELEASE_TAG is not set'); - -const [owner, repo] = repository.split('/'); -if (!owner || !repo) throw new Error(`Invalid repository: ${repository}`); - -const installerPath = await findInstaller(installerDir); -const tagNotes = await readTagNotes(tag); -const release = await ensureRelease(tagNotes); - -await uploadAsset(release, installerPath, 'application/octet-stream'); -await uploadAsset(release, latestJsonPath, 'application/json'); - -console.log(`GitHub release publish finished for ${tag}`); - -async function findInstaller(dir) { - const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); - const exe = entries.find((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.exe')); - if (!exe) { - throw new Error(`NSIS installer not found under ${dir}`); - } - return path.join(dir, exe.name); -} - -async function readTagNotes(tagName) { - try { - const refResponse = await githubApi(`/repos/${owner}/${repo}/git/ref/tags/${encodeURIComponent(tagName)}`, { - okStatuses: [200, 404], - }); - - if (refResponse.status === 404) { - console.log(`Tag ref ${tagName} not found on GitHub, fallback to generated release notes`); - return ''; - } - - const target = refResponse.json?.object; - if (!target || target.type !== 'tag' || !target.sha) { - console.log(`Tag ${tagName} is not an annotated tag on GitHub, fallback to generated release notes`); - return ''; - } - - const tagObject = await githubApi(`/repos/${owner}/${repo}/git/tags/${target.sha}`, { - okStatuses: [200, 404], - }); - - if (tagObject.status === 404) { - console.log(`Annotated tag object for ${tagName} not found on GitHub, fallback to generated release notes`); - return ''; - } - - return (tagObject.json?.message || '').trim(); - } catch (error) { - console.warn(`Failed to read GitHub tag notes for ${tagName}: ${error.message}`); - return ''; - } -} - -async function ensureRelease(tagNotes) { - const existing = await githubApi( - `/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`, - { okStatuses: [200, 404] } - ); - - if (existing.status === 200) { - console.log(`Using existing GitHub release for ${tag}`); - return await syncExistingRelease(existing.json, tagNotes); - } - - console.log(`Creating GitHub release for ${tag}`); - const created = await githubApi(`/repos/${owner}/${repo}/releases`, { - method: 'POST', - json: buildReleasePayload(tagNotes), - okStatuses: [201, 422], - }); - - if (created.status === 201) { - return created.json; - } - - const refetched = await githubApi(`/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`); - return refetched.json; -} - -async function syncExistingRelease(release, tagNotes) { - const desiredBody = tagNotes || ''; - const currentBody = release.body || ''; - const shouldUpdateBody = Boolean(tagNotes) && currentBody !== desiredBody; - const shouldEnableGeneratedNotes = !tagNotes && !currentBody; - - if (!shouldUpdateBody && !shouldEnableGeneratedNotes) { - return release; - } - - console.log(`Updating GitHub release metadata for ${tag}`); - const updated = await githubApi(`/repos/${owner}/${repo}/releases/${release.id}`, { - method: 'PATCH', - json: buildReleasePayload(tagNotes), - okStatuses: [200], - }); - - return updated.json; -} - -function buildReleasePayload(tagNotes) { - const payload = { - tag_name: tag, - name: tag, - }; - - if (tagNotes) { - payload.body = tagNotes; - } else { - payload.generate_release_notes = true; - } - - return payload; -} - -async function uploadAsset(release, filePath, contentType) { - const fileName = path.basename(filePath); - const fileBuffer = await fs.readFile(filePath); - - await deleteExistingAsset(release, fileName); - - const uploadUrl = release.upload_url.replace('{?name,label}', `?name=${encodeURIComponent(fileName)}`); - console.log(`Uploading ${fileName}`); - - await retry(`upload ${fileName}`, async () => { - const response = await fetch(uploadUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'Content-Type': contentType, - 'Content-Length': String(fileBuffer.length), - }, - body: fileBuffer, - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`upload failed ${response.status}: ${text}`); - } - }); -} - -async function deleteExistingAsset(release, fileName) { - const asset = (release.assets || []).find((item) => item.name === fileName); - if (!asset) return; - - console.log(`Deleting existing asset ${fileName}`); - await githubApi(`/repos/${owner}/${repo}/releases/assets/${asset.id}`, { - method: 'DELETE', - okStatuses: [204], - }); -} - -async function githubApi(apiPath, options = {}) { - const url = apiPath.startsWith('http') ? apiPath : `https://api.github.com${apiPath}`; - const headers = { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - ...options.headers, - }; - - let body; - if (options.json !== undefined) { - body = JSON.stringify(options.json); - headers['Content-Type'] = 'application/json'; - } else if (options.body !== undefined) { - body = options.body; - } - - const response = await fetch(url, { - method: options.method || 'GET', - headers, - body, - }); - - const okStatuses = options.okStatuses || [200]; - const contentType = response.headers.get('content-type') || ''; - const payload = contentType.includes('application/json') - ? await response.json().catch(() => null) - : await response.text().catch(() => ''); - - if (!okStatuses.includes(response.status)) { - throw new Error(`${options.method || 'GET'} ${url} failed ${response.status}: ${formatPayload(payload)}`); - } - - return { status: response.status, json: payload }; -} - -async function retry(label, fn, attempts = 4) { - let lastError; - for (let attempt = 1; attempt <= attempts; attempt += 1) { - try { - await fn(); - return; - } catch (error) { - lastError = error; - if (attempt === attempts) break; - const delayMs = attempt * 2000; - console.warn(`${label} failed on attempt ${attempt}/${attempts}: ${error.message}`); - console.warn(`Retrying in ${delayMs}ms`); - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - } - throw lastError; -} - -function formatPayload(payload) { - if (typeof payload === 'string') return payload; - try { - return JSON.stringify(payload); - } catch { - return String(payload); - } -} diff --git a/deploy/publish-version.mjs b/deploy/publish-version.mjs deleted file mode 100644 index 6c918e09..00000000 --- a/deploy/publish-version.mjs +++ /dev/null @@ -1,105 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import http from 'node:http'; -import https from 'node:https'; -import { URL } from 'node:url'; -import crypto from 'node:crypto'; - -const apiUrl = process.env.VERSION_API_URL; -const apiKey = process.env.VERSION_API_KEY; -const releaseTag = process.env.RELEASE_TAG; -const releaseNotes = process.env.RELEASE_NOTES; - -if (!apiUrl) throw new Error('VERSION_API_URL is not set'); -if (!apiKey) throw new Error('VERSION_API_KEY is not set'); -if (!releaseTag) throw new Error('RELEASE_TAG is not set'); - -const version = releaseTag.replace(/^v/, ''); -const notes = (releaseNotes || '').trim() || 'Automated release'; -const exePath = path.resolve('src-tauri/target/release/simprint.exe'); - -if (!fs.existsSync(exePath)) { - throw new Error(`Executable not found at ${exePath}`); -} - -const boundary = `----simprint-${cryptoRandomString(16)}`; -const fields = [ - 'type_id', - 'resource_name', - 'version', - 'name', - 'notes', - 'platform', - 'pub_date', -]; -const values = { - type_id: '1', - resource_name: 'simprint.exe', - version, - name: `simprint-${version}.exe`, - notes, - platform: 'windows', - pub_date: new Date().toISOString(), -}; - -const startParts = fields - .map( - (field) => - `--${boundary}\r\nContent-Disposition: form-data; name="${field}"\r\n\r\n${values[field]}\r\n` - ) - .join(''); -const fileHeader = - `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="simprint.exe"\r\nContent-Type: application/octet-stream\r\n\r\n`; -const endBoundary = `\r\n--${boundary}--\r\n`; - -const preamble = Buffer.from(startParts + fileHeader, 'utf8'); -const ending = Buffer.from(endBoundary, 'utf8'); -const fileStats = fs.statSync(exePath); - -const url = new URL(apiUrl); -const isHttps = url.protocol === 'https:'; -const requestFn = isHttps ? https.request : http.request; -const port = url.port ? Number(url.port) : isHttps ? 443 : 80; -const basePath = url.pathname === '/' ? '' : url.pathname.replace(/\/$/, ''); -const requestPath = `${basePath}/api/v1/versions/create${url.search}`; -const options = { - method: 'POST', - hostname: url.hostname, - port, - path: requestPath, - headers: { - 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'X-API-KEY': apiKey, - 'Content-Length': preamble.length + fileStats.size + ending.length, - }, -}; -console.log(options); - -const { statusCode, body } = await new Promise((resolve, reject) => { - const req = requestFn(options, (res) => { - const chunks = []; - res.on('data', (chunk) => chunks.push(chunk)); - res.on('end', () => - resolve({ - statusCode: res.statusCode, - body: Buffer.concat(chunks).toString('utf8'), - }) - ); - }); - req.on('error', reject); - req.write(preamble); - const stream = fs.createReadStream(exePath); - stream.on('error', reject); - stream.pipe(req, { end: false }); - stream.on('end', () => req.end(ending)); -}); - -if (statusCode < 200 || statusCode >= 300) { - throw new Error(`versions/create returned ${statusCode}: ${body}`); -} - -console.log('version metadata published', body); - -function cryptoRandomString(length) { - return crypto.randomBytes(length).toString('hex').slice(0, length); -} diff --git a/deploy/verify-updater-manifest.mjs b/deploy/verify-updater-manifest.mjs new file mode 100644 index 00000000..b84d642d --- /dev/null +++ b/deploy/verify-updater-manifest.mjs @@ -0,0 +1,72 @@ +import fs from 'node:fs'; + +const EXPECTED_ASSET_TOKEN = { + embedBootstrapper: 'embedBootstrapper', + 'fixed-runtime': 'fixed-runtime', +}; + +const REQUIRED_PLATFORMS = ['windows-x86_64', 'windows-aarch64', 'windows-i686']; +const [manifestPath, mode, releaseMetadataPath] = process.argv.slice(2); +const expectedAssetToken = EXPECTED_ASSET_TOKEN[mode]; + +if (!manifestPath || !expectedAssetToken || !releaseMetadataPath) { + throw new Error( + 'Usage: node deploy/verify-updater-manifest.mjs ' + ); +} + +function readJson(path) { + return JSON.parse(fs.readFileSync(path, 'utf8').replace(/^\uFEFF/, '')); +} + +const manifest = readJson(manifestPath); +const release = readJson(releaseMetadataPath); +const platforms = manifest.platforms ?? {}; +const assets = release.assets ?? []; + +function resolveAsset(url) { + const directMatch = assets.find( + (asset) => asset.url === url || asset.browser_download_url === url + ); + if (directMatch) { + return directMatch; + } + + const assetId = /\/releases\/assets\/(\d+)$/.exec(new URL(url).pathname)?.[1]; + return assetId ? assets.find((asset) => String(asset.id) === assetId) : undefined; +} + +function verifyEntry(platform, entry) { + if (!entry.signature?.trim()) { + throw new Error(`${manifestPath} platform "${platform}" is missing its signature`); + } + + const asset = entry.url ? resolveAsset(entry.url) : undefined; + if (!asset) { + throw new Error(`${manifestPath} platform "${platform}" does not reference a release asset`); + } + if (!asset.name.includes(expectedAssetToken)) { + throw new Error( + `${manifestPath} platform "${platform}" points to another mode's asset: "${asset.name}"` + ); + } +} + +for (const platform of REQUIRED_PLATFORMS) { + const entry = platforms[platform]; + + if (!entry) { + throw new Error(`${manifestPath} is missing required updater platform "${platform}"`); + } + verifyEntry(platform, entry); +} + +for (const [platform, entry] of Object.entries(platforms)) { + if (platform.startsWith('windows-') && !REQUIRED_PLATFORMS.includes(platform)) { + verifyEntry(platform, entry); + } +} + +console.log( + `[verify-updater-manifest] ${manifestPath} contains ${mode} updates for x64, ARM64 and x86` +); diff --git a/index.html b/index.html index 8c9de494..56e36e69 100644 --- a/index.html +++ b/index.html @@ -5,10 +5,12 @@ Simprint | 核心环境管理系统 -
+ diff --git a/package.json b/package.json index df3f642e..2015bbac 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,13 @@ "type": "module", "scripts": { "dev": "slotkit generate-imports && slotkit dev", - "build:development": "vite build --mode development", - "build:production": "vite build --mode production", - "build:test": "vite build --mode test", + "build:development": "slotkit generate-imports && vite build --mode development", + "build:production": "slotkit generate-imports && vite build --mode production", + "build:test": "slotkit generate-imports && vite build --mode test", "deploy:prepare-version": "node deploy/prepare-version.mjs", "deploy:build": "node deploy/build.mjs", - "deploy:latest-json": "node deploy/generate-latest-json.mjs", + "bundle:embed-bootstrapper": "node deploy/build.mjs embedBootstrapper", + "bundle:fixed-runtime": "node deploy/build.mjs fixed-runtime", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write \"**/*.{ts,tsx,json,css,md}\"", @@ -60,6 +61,7 @@ "@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-store": "^2.4.2", + "@tauri-apps/plugin-updater": "2.9.0", "@xyflow/react": "^12.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/plugins/layouts/app-layout/src/components/app-sidebar.tsx b/plugins/layouts/app-layout/src/components/app-sidebar.tsx index c2740e7a..4a1a38c2 100644 --- a/plugins/layouts/app-layout/src/components/app-sidebar.tsx +++ b/plugins/layouts/app-layout/src/components/app-sidebar.tsx @@ -4,17 +4,12 @@ import { Workflow, TerminalSquare, ShieldHalf, - SquarePlus, - Layout, - Monitor, FolderTree, ChevronLeft, ChevronRight, Puzzle, Users, UserCircle, - CreditCard, - Gift, } from 'lucide-react'; import { TfiWorld } from "react-icons/tfi"; import { BsWindowSidebar } from "react-icons/bs"; @@ -26,7 +21,6 @@ import { Link, useLocation, useNavigate } from 'react-router'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useTranslation } from 'react-i18next'; -import { FreeQuotaUsage } from './free-quota-usage'; import { getGeneralSettings, useMihomoRuntimeStore } from '../../../../services/store/src'; import { ClashIcon } from '../../../../pages/proxy-center/src/mihomo/clash-icon'; import { MihomoConnectDialog } from '../../../../pages/proxy-center/src/mihomo/mihomo-connect-dialog'; @@ -488,14 +482,12 @@ interface BottomNavItemsProps { /** * 底部导航项组件 - * 包含指纹审计、费用中心、推广计划 + * 底部保留本地审计入口。 */ const BottomNavItems: React.FC = ({ collapsed, currentPath }) => { const { t } = useTranslation('appLayout'); const bottomNavItems: NavItemData[] = [ { label: t('nav.item.audit'), href: '/audit', icon: ShieldHalf }, - { label: t('quota.billingCenter'), href: '/billing', icon: CreditCard }, - { label: t('quota.referralProgram'), href: '/referral', icon: Gift }, ]; return ( @@ -696,7 +688,6 @@ export const AppSidebar: React.FC = () => { {/* 免费额度使用情况区域 */}
-
{/* 折叠按钮区域 */} diff --git a/plugins/layouts/app-layout/src/components/free-quota-usage.tsx b/plugins/layouts/app-layout/src/components/free-quota-usage.tsx deleted file mode 100644 index 37852f65..00000000 --- a/plugins/layouts/app-layout/src/components/free-quota-usage.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { useEffect, useState } from 'react'; -import { AlertCircle, Package } from 'lucide-react'; -import { Link } from 'react-router'; -import { useTranslation } from 'react-i18next'; - -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { useAuthStore, useRefreshStore } from '../../../../services/store/src'; -import { getWorkspaceQuota, type WorkspaceQuotaDto } from '../api/workspace-quotas'; - -interface FreeQuotaUsageProps { - collapsed: boolean; - currentPath: string; -} - -// 折叠状态下的套餐选择按钮数据 -const getCollapsedButtonsData = (t: (key: string) => string) => [ - { - label: t('quota.planSelection'), - href: '/plans', - icon: Package, - tooltip: t('quota.planSelection'), - }, -]; - -/** - * 免费额度使用情况组件 - */ -export const FreeQuotaUsage: React.FC = ({ collapsed, currentPath }) => { - const { t } = useTranslation('appLayout'); - const workspacesRefreshKey = useRefreshStore((state) => state.workspaces); - const [quota, setQuota] = useState(null); - const [loading, setLoading] = useState(false); - - // 当侧边栏展开时,从服务器加载配额信息 - useEffect(() => { - if (!collapsed) { - setLoading(true); - void getWorkspaceQuota({}) - .then((data) => { - setQuota(data); - }) - .catch((error) => { - // eslint-disable-next-line no-console - console.error('Failed to load workspace quota:', error); - }) - .finally(() => { - setLoading(false); - }); - } - }, [collapsed, workspacesRefreshKey]); - - const total = quota?.max_environments ?? 6; - const used = quota?.used_environments ?? 0; - const percentage = total > 0 ? (used / total) * 100 : 0; - - if (collapsed) { - // 折叠状态下显示三个图标按钮 - const collapsedButtonsData = getCollapsedButtonsData(t); - return ( -
- {collapsedButtonsData.map((item) => { - const isActive = currentPath === item.href; - return ( - - - - {isActive &&
 
} - - -
- {item.tooltip} -
- ); - })} -
- ); - } - - // 展开状态下显示完整内容 - return ( - - {/* 使用剩余环境的进度 */} -
- {/* 免费用量 */} -
-
- {t('quota.freeUsage')} -
-
- - {loading ? '--/--' : `${used}/${total}`} - -
- - - - - - {t('quota.freeUsageDesc', { total, used })} - - -
-
- - {/* 进度条 */} -
-
-
-
-
- - ); -} - diff --git a/plugins/layouts/app-layout/src/components/titlebar/app-update-button.tsx b/plugins/layouts/app-layout/src/components/titlebar/app-update-button.tsx new file mode 100644 index 00000000..a57206f4 --- /dev/null +++ b/plugins/layouts/app-layout/src/components/titlebar/app-update-button.tsx @@ -0,0 +1,159 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Download, RefreshCw } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { DownloadEvent, Update } from '@tauri-apps/plugin-updater'; + +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { + APP_UPDATE_AVAILABLE_EVENT, + APP_UPDATE_DIALOG_EVENT, + checkForAppUpdate, + getAvailableAppUpdate, + installAvailableAppUpdate, +} from '@/lib/app-updater'; + +export function AppUpdateButton() { + const { t } = useTranslation('appLayout'); + const [update, setUpdate] = useState(() => getAvailableAppUpdate()); + const [open, setOpen] = useState(false); + const [installing, setInstalling] = useState(false); + const [downloaded, setDownloaded] = useState(0); + const [total, setTotal] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const handleAvailable = (event: Event) => { + const availableUpdate = (event as CustomEvent).detail; + setUpdate(availableUpdate); + setError(null); + setOpen(true); + }; + const handleOpen = () => { + if (getAvailableAppUpdate()) { + setOpen(true); + } + }; + + window.addEventListener(APP_UPDATE_AVAILABLE_EVENT, handleAvailable); + window.addEventListener(APP_UPDATE_DIALOG_EVENT, handleOpen); + + // One non-blocking check is performed after the authenticated main layout mounts. + void checkForAppUpdate().catch((checkError) => { + console.warn('[Updater] Automatic update check failed:', checkError); + }); + + return () => { + window.removeEventListener(APP_UPDATE_AVAILABLE_EVENT, handleAvailable); + window.removeEventListener(APP_UPDATE_DIALOG_EVENT, handleOpen); + }; + }, []); + + const progress = useMemo(() => { + if (!total || total <= 0) return null; + return Math.min(100, Math.round((downloaded / total) * 100)); + }, [downloaded, total]); + + const handleDownloadEvent = (event: DownloadEvent) => { + if (event.event === 'Started') { + setDownloaded(0); + setTotal(event.data.contentLength ?? null); + } else if (event.event === 'Progress') { + setDownloaded((value) => value + event.data.chunkLength); + } + }; + + const handleInstall = async () => { + setInstalling(true); + setDownloaded(0); + setTotal(null); + setError(null); + + try { + await installAvailableAppUpdate(handleDownloadEvent); + } catch (installError) { + console.error('[Updater] Update installation failed:', installError); + setError(t('update.installFailed')); + setInstalling(false); + } + }; + + if (!update) { + return null; + } + + return ( + <> + + + + + {t('update.available')} + + + !installing && setOpen(nextOpen)}> + + +
+
+ +
+
+ {t('update.dialogTitle')} + + {t('update.versionChange', { + current: update.currentVersion, + next: update.version, + })} + +
+
+
+ +
+

{t('update.releaseNotes')}

+
+ {update.body?.trim() || t('update.noReleaseNotes')} +
+ + {installing ? ( +
+
+ {t('update.downloading')} + {progress == null ? t('update.preparing') : `${progress}%`} +
+ +
+ ) : null} + + {error ?

{error}

: null} +
+ + + {t('update.later')} + + +
+
+ + ); +} diff --git a/plugins/layouts/app-layout/src/components/titlebar/default-navigation-slot.tsx b/plugins/layouts/app-layout/src/components/titlebar/default-navigation-slot.tsx index 9d875150..312e5a52 100644 --- a/plugins/layouts/app-layout/src/components/titlebar/default-navigation-slot.tsx +++ b/plugins/layouts/app-layout/src/components/titlebar/default-navigation-slot.tsx @@ -1,7 +1,6 @@ import { ChevronRight, Home, - List, FolderTree, Network, UserCircle, @@ -9,9 +8,6 @@ import { TerminalSquare, Puzzle, Users, - Package, - CreditCard, - Gift, ShieldHalf, Settings, SquarePlus, @@ -32,9 +28,6 @@ const navItems: Array<{ key: string; href: string; icon: LucideIcon | IconType } { key: 'api', href: '/api', icon: TerminalSquare }, { key: 'extensions', href: '/extensions', icon: Puzzle }, { key: 'team', href: '/team', icon: Users }, - { key: 'plans', href: '/plans', icon: Package }, - { key: 'billing', href: '/billing', icon: CreditCard }, - { key: 'referral', href: '/referral', icon: Gift }, { key: 'audit', href: '/audit', icon: ShieldHalf }, { key: 'settings', href: '/settings', icon: Settings }, { key: 'createWindow', href: '/create-window', icon: SquarePlus }, diff --git a/plugins/layouts/app-layout/src/components/titlebar/default-status-slot.tsx b/plugins/layouts/app-layout/src/components/titlebar/default-status-slot.tsx index 35babe1f..a54ee07c 100644 --- a/plugins/layouts/app-layout/src/components/titlebar/default-status-slot.tsx +++ b/plugins/layouts/app-layout/src/components/titlebar/default-status-slot.tsx @@ -1,20 +1,20 @@ import { NotificationMenu } from './notification-menu'; import { DownloadMenu } from './download-menu'; -import { PreparedUpdateButton } from './prepared-update-button'; +import { AppUpdateButton } from './app-update-button'; import { UserMenu } from './user-menu'; - -/** - * 默认状态信息区域组件 - */ -export function DefaultStatusSlot() { - return ( + +/** + * 默认状态信息区域组件 + */ +export function DefaultStatusSlot() { + return ( <>
- +
- ); -} + ); +} diff --git a/plugins/layouts/app-layout/src/components/titlebar/hooks/use-messages.ts b/plugins/layouts/app-layout/src/components/titlebar/hooks/use-messages.ts deleted file mode 100644 index 26efa7dc..00000000 --- a/plugins/layouts/app-layout/src/components/titlebar/hooks/use-messages.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - listMessages, - markMessageRead, - batchMarkMessagesRead, - getMessageStats, - handleMessage, - type Message, - type MessageListRequest, -} from '../../../api/messages'; -import { acceptInvitation, rejectInvitation } from '../../../../../../pages/team/src/api'; -import { useRefreshStore } from '../../../../../../services/store/src'; -import { toast } from 'sonner'; - -export interface UseMessagesReturn { - messages: Message[]; - stats: { - total: number; - unread: number; - by_type: Record; - } | null; - loading: boolean; - error: string | null; - currentPage: number; - totalPages: number; - messageTypeFilter: string | null; - isReadFilter: boolean | null; - refresh: () => Promise; - loadMore: () => Promise; - markAsRead: (messageUuid: string) => Promise; - markAllAsRead: () => Promise; - handleInvitation: (message: Message, action: 'accept' | 'reject') => Promise; - setMessageTypeFilter: (type: string | null) => void; - setIsReadFilter: (isRead: boolean | null) => void; -} - -const PAGE_SIZE = 20; - -/** - * 消息管理 Hook - */ -export function useMessages(): UseMessagesReturn { - const { t } = useTranslation('appLayout'); - const [messages, setMessages] = useState([]); - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [currentPage, setCurrentPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const [messageTypeFilter, setMessageTypeFilter] = useState(null); - const [isReadFilter, setIsReadFilter] = useState(null); - - // 加载消息列表 - const loadMessages = useCallback( - async (page: number = 1, append: boolean = false) => { - setLoading(true); - setError(null); - - try { - const request: MessageListRequest = { - page, - page_size: PAGE_SIZE, - filters: { - ...(messageTypeFilter && { message_type: messageTypeFilter }), - ...(isReadFilter !== null && { is_read: isReadFilter }), - }, - }; - - const response = await listMessages(request); - - if (append) { - setMessages((prev) => [...prev, ...response.items]); - } else { - setMessages(response.items); - } - - setCurrentPage(page); - setTotalPages(Math.ceil(response.total / PAGE_SIZE)); - } catch (e) { - const errorMessage = e instanceof Error ? e.message : '加载消息失败'; - setError(errorMessage); - console.error('Failed to load messages:', e); - } finally { - setLoading(false); - } - }, - [messageTypeFilter, isReadFilter] - ); - - // 加载消息统计 - const loadStats = useCallback(async () => { - try { - const statsData = await getMessageStats(); - setStats(statsData); - } catch (e) { - console.error('Failed to load message stats:', e); - } - }, []); - - // 刷新消息列表 - const refresh = useCallback(async () => { - await Promise.all([loadMessages(1, false), loadStats()]); - }, [loadMessages, loadStats]); - - // 加载更多 - const loadMore = useCallback(async () => { - if (currentPage < totalPages && !loading) { - await loadMessages(currentPage + 1, true); - } - }, [currentPage, totalPages, loading, loadMessages]); - - // 标记为已读 - const markAsRead = useCallback( - async (messageUuid: string) => { - try { - await markMessageRead(messageUuid); - setMessages((prev) => - prev.map((msg) => - msg.message_uuid === messageUuid - ? { ...msg, is_read: true, read_at: new Date().toISOString() } - : msg - ) - ); - // 更新统计 - if (stats) { - setStats({ - ...stats, - unread: Math.max(0, stats.unread - 1), - }); - } - } catch (e) { - toast.error(e instanceof Error ? e.message : '标记已读失败'); - } - }, - [stats] - ); - - // 标记所有为已读 - const markAllAsRead = useCallback(async () => { - const unreadMessages = messages.filter((msg) => !msg.is_read); - if (unreadMessages.length === 0) return; - - try { - const unreadUuids = unreadMessages.map((msg) => msg.message_uuid); - await batchMarkMessagesRead(unreadUuids); - - setMessages((prev) => - prev.map((msg) => ({ - ...msg, - is_read: true, - read_at: msg.read_at || new Date().toISOString(), - })) - ); - - // 更新统计 - if (stats) { - setStats({ - ...stats, - unread: 0, - }); - } - - toast.success(`已标记 ${unreadUuids.length} 条消息为已读`); - } catch (e) { - toast.error(e instanceof Error ? e.message : '批量标记已读失败'); - } - }, [messages, stats]); - - // 处理邀请(接受/拒绝) - const handleInvitation = useCallback( - async (message: Message, action: 'accept' | 'reject') => { - // 检查是否是团队邀请消息 - if (message.message_type !== 'team_invitation') { - toast.error(t('notification.acceptInvitationFailed')); - return; - } - - // 检查是否已经处理过 - if (message.action_status === 'accepted' || message.action_status === 'rejected') { - toast.error(t('notification.acceptInvitationFailed')); - return; - } - - // 检查 metadata 中是否有 token - if (!message.metadata || !message.metadata.token) { - toast.error(t('notification.acceptInvitationFailed')); - return; - } - - try { - if (action === 'accept') { - // 先接受邀请 - const teamUuid = await acceptInvitation({ token: message.metadata.token as string }); - - // 然后更新消息状态 - await handleMessage({ - message_uuid: message.message_uuid, - action: 'accept', - }); - - // 标记消息为已读(如果还未读) - if (!message.is_read) { - await markAsRead(message.message_uuid); - } - - // 更新本地状态 - setMessages((prev) => - prev.map((msg) => - msg.message_uuid === message.message_uuid - ? { - ...msg, - action_status: 'accepted', - action_at: new Date().toISOString(), - is_read: true, - read_at: message.read_at || new Date().toISOString(), - } - : msg - ) - ); - - // 触发团队数据刷新(让 user-menu 和 team-header 重新加载团队列表) - useRefreshStore.getState().refreshTeams(); - - toast.success(t('notification.acceptInvitationSuccess')); - } else { - // 先拒绝邀请(更新 team_invitations 表的状态) - await rejectInvitation({ token: message.metadata.token as string }); - - // 然后更新消息状态 - await handleMessage({ - message_uuid: message.message_uuid, - action: 'reject', - }); - - // 标记消息为已读(如果还未读) - if (!message.is_read) { - await markAsRead(message.message_uuid); - } - - // 更新本地状态 - setMessages((prev) => - prev.map((msg) => - msg.message_uuid === message.message_uuid - ? { - ...msg, - action_status: 'rejected', - action_at: new Date().toISOString(), - is_read: true, - read_at: message.read_at || new Date().toISOString(), - } - : msg - ) - ); - - toast.success(t('notification.rejectInvitationSuccess')); - } - - // 刷新统计 - await loadStats(); - } catch (e) { - const errorMessage = - e instanceof Error - ? e.message - : action === 'accept' - ? t('notification.acceptInvitationFailed') - : t('notification.rejectInvitationFailed'); - toast.error(errorMessage); - console.error('Failed to handle invitation:', e); - } - }, - [loadStats, t, markAsRead] - ); - - // 初始加载和筛选条件改变时重新加载 - useEffect(() => { - void refresh(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [messageTypeFilter, isReadFilter]); - - return { - messages, - stats, - loading, - error, - currentPage, - totalPages, - messageTypeFilter, - isReadFilter, - refresh, - loadMore, - markAsRead, - markAllAsRead, - handleInvitation, - setMessageTypeFilter, - setIsReadFilter, - }; -} diff --git a/plugins/layouts/app-layout/src/components/titlebar/messages-dialog.tsx b/plugins/layouts/app-layout/src/components/titlebar/messages-dialog.tsx index 58f4fa41..bab5d456 100644 --- a/plugins/layouts/app-layout/src/components/titlebar/messages-dialog.tsx +++ b/plugins/layouts/app-layout/src/components/titlebar/messages-dialog.tsx @@ -1,13 +1,10 @@ import { useEffect, useMemo } from 'react'; import { BellOff, - Check, CheckCheck, - CheckCircle2, Inbox, Info, Loader2, - X, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { format, isToday, isYesterday } from 'date-fns'; @@ -49,7 +46,6 @@ export function MessagesDialog({ open, onOpenChange }: MessagesDialogProps) { const loadMore = useMessagesStore((state) => state.loadMore); const markAsRead = useMessagesStore((state) => state.markAsRead); const markAllAsRead = useMessagesStore((state) => state.markAllAsRead); - const handleInvitation = useMessagesStore((state) => state.handleInvitation); const setMessageTypeFilter = useMessagesStore((state) => state.setMessageTypeFilter); const setIsReadFilter = useMessagesStore((state) => state.setIsReadFilter); @@ -116,15 +112,6 @@ export function MessagesDialog({ open, onOpenChange }: MessagesDialogProps) { } }; - const handleInvitationClick = async ( - e: React.MouseEvent, - message: (typeof messages)[0], - action: 'accept' | 'reject' - ) => { - e.stopPropagation(); - await handleInvitation(message, action); - }; - const handleTypeFilter = (type: string | null) => { if (type === 'team') { setMessageTypeFilter(TEAM_MESSAGE_TYPES[0]); @@ -337,41 +324,6 @@ export function MessagesDialog({ open, onOpenChange }: MessagesDialogProps) {

) : null} - {message.message_type === 'team_invitation' ? ( -
- {message.action_status === 'accepted' ? ( - - - {t('notification.invitationAccepted')} - - ) : message.action_status === 'rejected' ? ( - - - {t('notification.invitationRejected')} - - ) : ( - <> - - - - )} -
- ) : null}
diff --git a/plugins/layouts/app-layout/src/components/titlebar/notification-menu.tsx b/plugins/layouts/app-layout/src/components/titlebar/notification-menu.tsx index bbd9e6c1..6208194b 100644 --- a/plugins/layouts/app-layout/src/components/titlebar/notification-menu.tsx +++ b/plugins/layouts/app-layout/src/components/titlebar/notification-menu.tsx @@ -6,12 +6,9 @@ import { Loader2, Eye, Info, - UserPlus, UserMinus, MessageSquare, AlertCircle, - Check, - X, ChevronRight, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -34,7 +31,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip // 消息类型映射 const MESSAGE_TYPE_MAP: Record = { system_notification: 'notification.systemNotification', - team_invitation: 'notification.teamInvitation', team_removal: 'notification.teamRemoval', team_announcement: 'notification.teamAnnouncement', private_chat: 'notification.privateChat', @@ -43,7 +39,6 @@ const MESSAGE_TYPE_MAP: Record = { // 消息类型图标映射 const MESSAGE_TYPE_ICONS: Record = { system_notification: Info, - team_invitation: UserPlus, team_removal: UserMinus, team_announcement: MessageSquare, private_chat: MessageSquare, @@ -70,7 +65,6 @@ export function NotificationMenu() { const loadMore = useMessagesStore((state) => state.loadMore); const markAsRead = useMessagesStore((state) => state.markAsRead); const markAllAsRead = useMessagesStore((state) => state.markAllAsRead); - const handleInvitation = useMessagesStore((state) => state.handleInvitation); const setMessageTypeFilter = useMessagesStore((state) => state.setMessageTypeFilter); const setIsReadFilter = useMessagesStore((state) => state.setIsReadFilter); @@ -112,7 +106,6 @@ export function NotificationMenu() { const getMessageTypeIconColor = (type: string) => { const colorMap: Record = { system_notification: 'text-blue-500', - team_invitation: 'text-green-500', team_removal: 'text-red-500', team_announcement: 'text-purple-500', private_chat: 'text-orange-500', @@ -127,16 +120,6 @@ export function NotificationMenu() { } }; - // 处理邀请按钮点击 - const handleInvitationClick = async ( - e: React.MouseEvent, - message: (typeof messages)[0], - action: 'accept' | 'reject' - ) => { - e.stopPropagation(); - await handleInvitation(message, action); - }; - // 获取已读状态显示文本 const getReadStatusText = () => { if (isReadFilter === null) return t('notification.filterAll'); @@ -331,44 +314,6 @@ export function NotificationMenu() {

)} - {/* 团队邀请操作按钮 */} - {message.message_type === 'team_invitation' && ( -
- {message.action_status === 'accepted' ? ( -
- - {t('notification.invitationAccepted')} -
- ) : message.action_status === 'rejected' ? ( -
- - {t('notification.invitationRejected')} -
- ) : ( -
- - -
- )} -
- )} - {/* 底部元信息 */}
diff --git a/plugins/layouts/app-layout/src/components/titlebar/prepared-update-button.tsx b/plugins/layouts/app-layout/src/components/titlebar/prepared-update-button.tsx deleted file mode 100644 index 6fc63431..00000000 --- a/plugins/layouts/app-layout/src/components/titlebar/prepared-update-button.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useEffect, useState } from 'react'; -import { RefreshCw } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; - -import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; -import { Button } from '@/components/ui/button'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { invoke } from '@/lib/tauri'; -import { listen } from '@tauri-apps/api/event'; - -interface PreparedUpdateInfo { - kind: string; - version: string; - restartRequired: boolean; -} - -export function PreparedUpdateButton() { - const { t } = useTranslation('appLayout'); - const [update, setUpdate] = useState(null); - const [open, setOpen] = useState(false); - const [installing, setInstalling] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - let disposed = false; - let unlisten: (() => void) | undefined; - - const setup = async () => { - const preparedUpdate = await invoke('get_prepared_update'); - if (!disposed && preparedUpdate) { - setUpdate(preparedUpdate); - setError(null); - } - - unlisten = await listen('app-update-ready', (event) => { - if (disposed) { - return; - } - - setUpdate(event.payload); - setError(null); - }); - }; - - void setup(); - - return () => { - disposed = true; - unlisten?.(); - }; - }, []); - - const handleInstall = async () => { - if (!update) { - return; - } - - setInstalling(true); - setError(null); - - try { - await invoke('start_prepared_update_install', { - kind: update.kind, - }); - } catch (installError) { - const message = - installError instanceof Error ? installError.message : t('update.installStartFailed'); - setError(message); - setInstalling(false); - } - }; - - if (!update) { - return null; - } - - return ( - <> - - - - - {t('update.ready')} - - - - - - {t('update.dialogTitle')} - {t('update.dialogDescription')} - - - {error ?

{error}

: null} - - - {t('update.later')} - - -
-
- - ); -} diff --git a/plugins/layouts/app-layout/src/components/titlebar/user-menu.tsx b/plugins/layouts/app-layout/src/components/titlebar/user-menu.tsx index f12a6d07..700135c1 100644 --- a/plugins/layouts/app-layout/src/components/titlebar/user-menu.tsx +++ b/plugins/layouts/app-layout/src/components/titlebar/user-menu.tsx @@ -22,7 +22,7 @@ import { DropdownMenuSubTrigger, DropdownMenuSubContent, } from '@/components/ui/dropdown-menu'; -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { useUserMenuConfig } from './hooks/use-user-menu-config'; import { getMyTeams, switchTeam, type TeamItem } from '../../api/teams'; import { useWorkspaceStore } from '../../../../../services/store/src/stores/workspace'; @@ -56,7 +56,7 @@ export function UserMenu() { const { setCurrentTeam } = useAuthStore(); const handleLogout = async () => { - // 先清理 Tauri 侧的 token / remembered credential,避免下次启动被自动登录拉起 + // 清理当前本地会话,返回用户选择界面。 try { const { invoke } = await import('@tauri-apps/api/core'); await invoke('logout'); @@ -125,11 +125,7 @@ export function UserMenu() { } }; - const displayName = - user && (user.nickname || user.email?.split('@')[0]) - ? user.nickname || user.email!.split('@')[0] - : 'User'; - const initial = displayName.charAt(0).toUpperCase(); + const displayName = user?.nickname || t('status.user'); // 获取当前工作空间 const currentWorkspace = workspaces.find((ws) => ws.is_current); @@ -143,11 +139,10 @@ export function UserMenu() { -
- ) : ( - - - - - - - - - - {services.map((service) => ( - - - - - - ))} - -
{t('autoRenewal.serviceDetails')}{t('autoRenewal.renewalPrice')}{t('autoRenewal.nextBillDate')}
{service.serviceName} - ${service.renewalPrice} {service.currency} - {service.nextBillDate}
- )} -
- - ); -} diff --git a/plugins/pages/billing-center/src/components/billing-page-skeleton.tsx b/plugins/pages/billing-center/src/components/billing-page-skeleton.tsx deleted file mode 100644 index 3857db22..00000000 --- a/plugins/pages/billing-center/src/components/billing-page-skeleton.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -export function BillingPageSkeleton() { - return ( -
-
-
- {/* 头部区域 */} -
- - -
- - {/* 统计卡片区域 */} -
- {Array.from({ length: 4 }).map((_, index) => ( -
- - - -
- ))} -
- - {/* 表格区域 */} -
-
- -
-
- {Array.from({ length: 5 }).map((_, index) => ( -
- - - - - - - -
- ))} -
-
-
-
-
- ); -} diff --git a/plugins/pages/billing-center/src/components/billing-pagination.tsx b/plugins/pages/billing-center/src/components/billing-pagination.tsx deleted file mode 100644 index 3b7a2089..00000000 --- a/plugins/pages/billing-center/src/components/billing-pagination.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, - PaginationEllipsis, -} from '@/components/ui/pagination'; - -interface BillingPaginationProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; -} - -/** - * 账单分页组件 - */ -export function BillingPagination({ - currentPage, - totalPages, - onPageChange, -}: BillingPaginationProps) { - const { t } = useTranslation('billing'); - - const getPageNumbers = () => { - const pages: (number | 'ellipsis')[] = []; - const maxVisible = 7; - - if (totalPages <= maxVisible) { - for (let i = 1; i <= totalPages; i++) { - pages.push(i); - } - } else if (currentPage <= 3) { - for (let i = 1; i <= 4; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(totalPages); - } else if (currentPage >= totalPages - 2) { - pages.push(1); - pages.push('ellipsis'); - for (let i = totalPages - 3; i <= totalPages; i++) pages.push(i); - } else { - pages.push(1); - pages.push('ellipsis'); - for (let i = currentPage - 1; i <= currentPage + 1; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(totalPages); - } - - return pages; - }; - - return ( -
-
- {t('invoices.pagination.pageInfo', { current: currentPage, total: totalPages })} -
- - - - { - e.preventDefault(); - if (currentPage > 1) onPageChange(currentPage - 1); - }} - className={ - currentPage === 1 || totalPages === 0 - ? 'pointer-events-none opacity-50' - : 'cursor-pointer' - } - > - {t('invoices.pagination.previous')} - - - - {getPageNumbers().map((page, index) => ( - - {page === 'ellipsis' ? ( - - ) : ( - { - e.preventDefault(); - onPageChange(page); - }} - isActive={page === currentPage} - className="cursor-pointer" - > - {page} - - )} - - ))} - - - { - e.preventDefault(); - if (currentPage < totalPages) onPageChange(currentPage + 1); - }} - className={ - currentPage === totalPages || totalPages === 0 - ? 'pointer-events-none opacity-50' - : 'cursor-pointer' - } - > - {t('invoices.pagination.next')} - - - - -
- ); -} diff --git a/plugins/pages/billing-center/src/components/billing-table-skeleton.tsx b/plugins/pages/billing-center/src/components/billing-table-skeleton.tsx deleted file mode 100644 index 603ac11a..00000000 --- a/plugins/pages/billing-center/src/components/billing-table-skeleton.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -interface BillingTableSkeletonProps { - rows?: number; -} - -export function BillingTableSkeleton({ rows = 8 }: BillingTableSkeletonProps) { - return ( - <> - {Array.from({ length: rows }).map((_, index) => ( - - {/* 复选框列 */} - -
- -
- - {/* 类型列 */} - - - - {/* 金额列 */} - - - - {/* 描述列 */} - - - - {/* 状态列 */} - - - - {/* 创建时间列 */} - - - - {/* 操作列 */} - - - - - ))} - - ); -} diff --git a/plugins/pages/billing-center/src/components/billing-tabs.tsx b/plugins/pages/billing-center/src/components/billing-tabs.tsx deleted file mode 100644 index 87c4a2c6..00000000 --- a/plugins/pages/billing-center/src/components/billing-tabs.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { cn } from '@/lib/utils'; - -type BillingTab = 'wallet' | 'invoices'; - -interface BillingTabsProps { - activeTab: BillingTab; - onTabChange: (tab: BillingTab) => void; -} - -/** - * 计费中心 Tab 导航组件 - */ -export function BillingTabs({ activeTab, onTabChange }: BillingTabsProps) { - const { t } = useTranslation('billing'); - - const tabs: { value: BillingTab; key: string }[] = [ - { value: 'wallet', key: 'tabs.wallet' }, - { value: 'invoices', key: 'tabs.invoices' }, - ]; - - return ( -
-
- {tabs.map((tab) => ( - - ))} -
-
- ); -} diff --git a/plugins/pages/billing-center/src/components/coupons-dialog.tsx b/plugins/pages/billing-center/src/components/coupons-dialog.tsx deleted file mode 100644 index 231d981c..00000000 --- a/plugins/pages/billing-center/src/components/coupons-dialog.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; -import { useState, useEffect } from 'react'; -import { Ticket } from 'lucide-react'; -import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; -import { Button } from '@/components/ui/button'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { getMyCoupons } from '../api'; -import type { UserCoupon } from '../api/index.types'; - -interface CouponsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -/** - * 优惠券列表弹窗组件 - */ -export function CouponsDialog({ open, onOpenChange }: CouponsDialogProps) { - const { t, i18n } = useTranslation('billing'); - const navigate = useNavigate(); - const [allCoupons, setAllCoupons] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - if (open) { - loadCoupons(); - } - }, [open]); - - const loadCoupons = async () => { - try { - setLoading(true); - // 获取所有优惠券(使用较大的 page_size) - const data = await getMyCoupons({ - pagination: { page: 1, page_size: 1000 }, - }); - setAllCoupons(data.items || []); - } catch (error) { - console.error('获取优惠券失败:', error); - setAllCoupons([]); - } finally { - setLoading(false); - } - }; - - const formatDiscount = (coupon: UserCoupon) => { - let value: number; - if (typeof coupon.discount_value === 'string') { - value = parseFloat(coupon.discount_value); - } else if (typeof coupon.discount_value === 'number') { - value = coupon.discount_value; - } else { - value = 0; - } - - if (isNaN(value) || value == null) { - return '-$0.00'; - } - - if (coupon.discount_type === 'percentage') { - return `-${value}%`; - } else { - return `-$${value.toFixed(2)}`; - } - }; - - const getStatusBadge = (status: string) => { - switch (status) { - case 'unused': - return ( - - {t('coupons.status.unused')} - - ); - case 'used': - return ( - - {t('coupons.status.used')} - - ); - case 'expired': - return ( - - {t('coupons.status.expired')} - - ); - default: - return null; - } - }; - - const formatDate = (dateStr: string) => { - const date = new Date(dateStr); - const locale = i18n.language === 'en-US' ? 'en-US' : 'zh-CN'; - return date.toLocaleDateString(locale); - }; - - return ( - - {loading ? ( -
- {[1, 2, 3, 4, 5].map((i) => ( -
- ))} -
- ) : allCoupons.length === 0 ? ( -
-
- -
-

{t('coupons.noCoupons')}

-

- {t('coupons.noCouponsDescription')} -

-
- ) : ( - -
- {allCoupons.map((coupon) => ( -
{ - onOpenChange(false); - navigate(`/plans?coupon=${coupon.code}`); - }} - > - {/* 背景装饰 */} -
- -
- - {/* 内容 */} -
-
-
-
- - - {coupon.name || coupon.code || t('coupons.unknown')} - -
- {coupon.description && ( -
- {coupon.description} -
- )} -
- {formatDiscount(coupon)} - {coupon.min_amount != null && (() => { - const minAmount = typeof coupon.min_amount === 'string' - ? parseFloat(coupon.min_amount) - : coupon.min_amount; - return minAmount != null && !isNaN(minAmount) && minAmount > 0 ? ( - - {t('coupons.minAmount', { - amount: minAmount.toFixed(2), - })} - - ) : null; - })()} -
-
- {getStatusBadge(coupon.status)} -
- {coupon.expires_at && ( -
- {t('coupons.expiresAt', { - date: formatDate(coupon.expires_at), - })} -
- )} -
-
- ))} -
-
- )} - - - - - - - ); -} diff --git a/plugins/pages/billing-center/src/components/coupons-list.tsx b/plugins/pages/billing-center/src/components/coupons-list.tsx deleted file mode 100644 index 23607d75..00000000 --- a/plugins/pages/billing-center/src/components/coupons-list.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; -import { useState, useEffect } from 'react'; -import { Ticket, ChevronRight } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { getAvailableCoupons } from '../api'; -import { CouponsDialog } from './coupons-dialog'; -import type { UserCoupon } from '../api/index.types'; - -/** - * 优惠券列表组件 - */ -export function CouponsList() { - const { t, i18n } = useTranslation('billing'); - const navigate = useNavigate(); - const [coupons, setCoupons] = useState([]); - const [loading, setLoading] = useState(true); - const [dialogOpen, setDialogOpen] = useState(false); - - useEffect(() => { - loadCoupons(); - }, []); - - const loadCoupons = async () => { - try { - setLoading(true); - const data = await getAvailableCoupons(); - setCoupons(data || []); - } catch (error) { - console.error('获取优惠券失败:', error); - setCoupons([]); - } finally { - setLoading(false); - } - }; - - const formatDiscount = (coupon: UserCoupon) => { - let value: number; - if (typeof coupon.discount_value === 'string') { - value = parseFloat(coupon.discount_value); - } else if (typeof coupon.discount_value === 'number') { - value = coupon.discount_value; - } else { - value = 0; - } - - if (isNaN(value) || value == null) { - return '-$0.00'; - } - - if (coupon.discount_type === 'percentage') { - return `-${value}%`; - } else { - return `-$${value.toFixed(2)}`; - } - }; - - const getStatusBadge = (status: string) => { - switch (status) { - case 'unused': - return ( - - {t('coupons.status.unused')} - - ); - case 'used': - return ( - - {t('coupons.status.used')} - - ); - case 'expired': - return ( - - {t('coupons.status.expired')} - - ); - default: - return null; - } - }; - - if (loading) { - return ( -
-
-

{t('coupons.title')}

-
-
- {[1, 2].map((i) => ( -
- ))} -
-
- ); - } - - if (coupons.length === 0) { - return ( -
-
-

{t('coupons.title')}

-
-
- -

{t('coupons.noCoupons')}

- -
-
- ); - } - - return ( -
-
-

{t('coupons.title')}

- -
-
- {coupons.slice(0, 5).map((coupon) => ( -
navigate(`/plans?coupon=${coupon.code}`)} - > - {/* 背景装饰 */} -
- -
- - {/* 内容 */} -
-
-
-
- - - {coupon.name || coupon.code || t('coupons.unknown')} - -
- {coupon.description && ( -
- {coupon.description} -
- )} -
- {formatDiscount(coupon)} - {coupon.min_amount != null && (() => { - const minAmount = typeof coupon.min_amount === 'string' - ? parseFloat(coupon.min_amount) - : coupon.min_amount; - return minAmount != null && !isNaN(minAmount) && minAmount > 0 ? ( - - {t('coupons.minAmount', { amount: minAmount.toFixed(2) })} - - ) : null; - })()} -
-
- {getStatusBadge(coupon.status)} -
- {coupon.expires_at && ( -
- {t('coupons.expiresAt', { - date: new Date(coupon.expires_at).toLocaleDateString( - i18n.language === 'en-US' ? 'en-US' : 'zh-CN' - ), - })} -
- )} -
-
- ))} -
- -
- ); -} diff --git a/plugins/pages/billing-center/src/components/current-plan-card.tsx b/plugins/pages/billing-center/src/components/current-plan-card.tsx deleted file mode 100644 index 2ae149f6..00000000 --- a/plugins/pages/billing-center/src/components/current-plan-card.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; -import { Package, Crown, Zap, Globe, Sparkles, TrendingUp } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import type { AccountInfo } from '../types'; - -interface CurrentPlanCardProps { - account: AccountInfo; -} - -/** - * 当前套餐信息卡片组件 - */ -export function CurrentPlanCard({ account }: CurrentPlanCardProps) { - const { t } = useTranslation('billing'); - const navigate = useNavigate(); - - return ( -
-
- {/* 背景装饰图标 */} -
- {account.planId === 'free' ? ( - <> - - - - ) : ( - <> - - - - - )} -
-
-
-
- {account.planId === 'free' ? ( - - ) : ( - - )} - {account.planName} -
-
- {account.environmentsLimit} {t('currentPlan.windows')} -
-
-
- - - {t('dailyLimits.create')}: {account.dailyCreateLimit} - -
-
- - - {t('dailyLimits.open')}: {account.dailyOpenLimit} - -
-
-
-
- {account.planId === 'free' && ( -
{t('currentPlan.premiumPrompt')}
- )} - -
-
-
-
- ); -} diff --git a/plugins/pages/billing-center/src/components/invoice-date-filter.tsx b/plugins/pages/billing-center/src/components/invoice-date-filter.tsx deleted file mode 100644 index 9b7a1e70..00000000 --- a/plugins/pages/billing-center/src/components/invoice-date-filter.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useState } from 'react'; -import { X, CalendarIcon } from 'lucide-react'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { Calendar } from '@/components/ui/calendar'; -import { format } from 'date-fns'; -import { zhCN } from 'date-fns/locale'; - -interface InvoiceDateFilterProps { - startDate: string; - endDate: string; - onStartDateChange: (value: string) => void; - onEndDateChange: (value: string) => void; -} - -/** - * 账单日期筛选组件 - */ -export function InvoiceDateFilter({ - startDate, - endDate, - onStartDateChange, - onEndDateChange, -}: InvoiceDateFilterProps) { - const [startDateOpen, setStartDateOpen] = useState(false); - const [endDateOpen, setEndDateOpen] = useState(false); - - const formatDateDisplay = (dateStr: string) => { - if (!dateStr) return null; - try { - return format(new Date(dateStr), 'MM/dd', { locale: zhCN }); - } catch { - return dateStr; - } - }; - - return ( -
- - - - - - { - onStartDateChange(date ? format(date, 'yyyy-MM-dd') : ''); - setStartDateOpen(false); - }} - /> - - - - - - - - - - { - onEndDateChange(date ? format(date, 'yyyy-MM-dd') : ''); - setEndDateOpen(false); - }} - /> - - - {(startDate || endDate) && ( - - )} -
- ); -} diff --git a/plugins/pages/billing-center/src/components/invoices-skeleton.tsx b/plugins/pages/billing-center/src/components/invoices-skeleton.tsx deleted file mode 100644 index 04ccdd63..00000000 --- a/plugins/pages/billing-center/src/components/invoices-skeleton.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; -import { BillingTableSkeleton } from './billing-table-skeleton'; - -export function InvoicesSkeleton() { - return ( -
- {/* 顶部搜索栏 */} -
-
- -
-
- -
-
- - {/* 统计信息 */} -
- {Array.from({ length: 5 }).map((_, index) => ( -
- - -
- ))} -
- - {/* 账单表格 */} -
-
-
- - - - {Array.from({ length: 7 }).map((_, index) => ( - - ))} - - - - - -
- -
-
-
-
- - {/* 分页 */} -
- -
- - - - - -
-
-
- ); -} diff --git a/plugins/pages/billing-center/src/components/invoices-tab.tsx b/plugins/pages/billing-center/src/components/invoices-tab.tsx deleted file mode 100644 index 706ee04b..00000000 --- a/plugins/pages/billing-center/src/components/invoices-tab.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { DollarSign, AlertCircle, CheckCircle2, ChevronDown, X } from 'lucide-react'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; -import { BillingTableSkeleton } from './billing-table-skeleton'; -import { InvoiceDateFilter } from './invoice-date-filter'; -import { BillingPagination } from './billing-pagination'; -import type { Invoice, InvoiceStatusFilter } from '../types'; - -interface InvoicesTabProps { - invoices: Invoice[]; - loading: boolean; - error: string | null; - searchQuery: string; - statusFilter: InvoiceStatusFilter; - typeFilter: string; - startDate: string; - endDate: string; - currentPage: number; - totalPages: number; - paginatedInvoices: Invoice[]; - stats: { - total: number; - todayCount: number; - weekCount: number; - todayAmount: number; - weekAmount: number; - }; - invoiceTypes: string[]; - onSearchChange: (value: string) => void; - onStatusFilterChange: (value: InvoiceStatusFilter) => void; - onTypeFilterChange: (value: string) => void; - onStartDateChange: (value: string) => void; - onEndDateChange: (value: string) => void; - onPageChange: (page: number) => void; - onRefresh: () => void; -} - -/** - * 账单标签页组件 - */ -export function InvoicesTab({ - invoices: _invoices, - loading, - error, - searchQuery, - statusFilter, - typeFilter, - startDate, - endDate, - currentPage, - totalPages, - paginatedInvoices, - stats, - invoiceTypes, - onSearchChange, - onStatusFilterChange, - onTypeFilterChange, - onStartDateChange, - onEndDateChange, - onPageChange, - onRefresh, -}: InvoicesTabProps) { - const { t } = useTranslation('billing'); - - return ( -
- {error && ( -
{t('invoices.error', { error })}
- )} - - {/* 账单表格 */} -
- -
- - - - {/* 类型筛选 */} - - - - {/* 状态筛选 */} - - - {/* 日期筛选 */} - - - - - {loading ? ( - - ) : paginatedInvoices.length === 0 ? ( - - - - ) : ( - paginatedInvoices.map((inv) => ( - - - - - - - - - )) - )} - -
-
- {t('invoices.table.type')} - {!typeFilter ? ( - - - - - - {invoiceTypes.map((type) => ( - onTypeFilterChange(type)} - className="text-xs cursor-pointer" - > - {type} - - ))} - - - ) : ( - <> - - {typeFilter} - - - - )} -
-
- {t('invoices.table.amount')} - - {t('invoices.table.description')} - -
- {t('invoices.table.status')} - {statusFilter === 'all' ? ( - - - - - - onStatusFilterChange('paid')} - className="text-xs cursor-pointer" - > - {t('invoices.statusPaid')} - - onStatusFilterChange('pending')} - className="text-xs cursor-pointer" - > - {t('invoices.statusPending')} - - onStatusFilterChange('failed')} - className="text-xs cursor-pointer" - > - {t('invoices.statusFailed')} - - - - ) : ( - <> - - {t( - `invoices.status${statusFilter.charAt(0).toUpperCase() + statusFilter.slice(1)}` - )} - - - - )} -
-
- {t('invoices.table.operator')} - -
- {t('invoices.table.date')} - -
-
- -
{t('invoices.noData')}
-
-
{inv.type}
-
-
- {inv.amount} {inv.currency} -
-
-
{inv.description}
-
- {inv.status === 'paid' && ( - - - {t('invoices.statusPaid')} - - )} - {inv.status === 'pending' && ( - - - {t('invoices.statusPending')} - - )} - {inv.status === 'failed' && ( - - - {t('invoices.statusFailed')} - - )} - -
{inv.operator}
-
-
- {inv.createdAt} -
-
-
- -
-
- - {/* 分页 */} - { - if (page < 1 || page > totalPages) return; - onPageChange(page); - }} - /> -
- ); -} diff --git a/plugins/pages/billing-center/src/components/promotion-card.tsx b/plugins/pages/billing-center/src/components/promotion-card.tsx deleted file mode 100644 index 6c9e260a..00000000 --- a/plugins/pages/billing-center/src/components/promotion-card.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; -import { Sparkles, ArrowRight } from 'lucide-react'; -import { Button } from '@/components/ui/button'; - -/** - * 优惠活动卡片组件 - */ -export function PromotionCard() { - const { t } = useTranslation('billing'); - const navigate = useNavigate(); - - return ( -
- {/* 背景装饰 */} -
- -
- - {/* 内容 */} -
-
- -

{t('promotion.title')}

-
-

{t('promotion.description')}

- -
-
- ); -} - diff --git a/plugins/pages/billing-center/src/components/resource-usage-card.tsx b/plugins/pages/billing-center/src/components/resource-usage-card.tsx deleted file mode 100644 index 258a8577..00000000 --- a/plugins/pages/billing-center/src/components/resource-usage-card.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { ArrowRight } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; -import type { ResourceUsage } from '../types'; -import { getUsagePercentage, getUsageStatus } from '../utils/resource-usage'; - -interface ResourceUsageCardProps { - resource: ResourceUsage; -} - -/** - * 资源使用情况卡片组件 - */ -export function ResourceUsageCard({ resource }: ResourceUsageCardProps) { - const { t } = useTranslation('billing'); - const navigate = useNavigate(); - const percentage = getUsagePercentage(resource.used, resource.limit); - getUsageStatus(percentage); // 计算状态(虽然未使用,但保留逻辑) - const Icon = resource.icon; - - return ( -
- {/* 管理链接 - 右上角 */} - {resource.link && ( - - )} -
- {/* 图标和名称 */} -
- - {resource.name} -
- - {/* 圆形进度条 */} -
- - {/* 外层轮廓圆 */} - - {/* 背景圆 */} - - {/* 进度圆 */} - = 90 - ? 'text-destructive' - : percentage >= 70 - ? 'text-warning' - : 'text-foreground' - }`} - /> - - {/* 中心文本 */} -
- {percentage.toFixed(0)}% -
-
- - {/* 使用量信息 */} -
- = 90 - ? 'text-destructive' - : percentage >= 70 - ? 'text-warning' - : 'text-foreground' - }`} - > - {resource.used}/{resource.limit} {resource.unit} - -
-
-
- ); -} diff --git a/plugins/pages/billing-center/src/components/wallet-card.tsx b/plugins/pages/billing-center/src/components/wallet-card.tsx deleted file mode 100644 index 43f90a65..00000000 --- a/plugins/pages/billing-center/src/components/wallet-card.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Wallet, Circle } from 'lucide-react'; - -interface WalletCardProps { - balance: number; - giftBalance: number; -} - -/** - * 钱包卡片组件 - 带 Canvas 装饰 - */ -export function WalletCard({ balance, giftBalance }: WalletCardProps) { - const { t } = useTranslation('billing'); - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const drawPattern = () => { - const dpr = window.devicePixelRatio || 1; - const rect = canvas.getBoundingClientRect(); - const width = rect.width; - const height = rect.height; - - canvas.width = width * dpr; - canvas.height = height * dpr; - ctx.scale(dpr, dpr); - canvas.style.width = width + 'px'; - canvas.style.height = height + 'px'; - - ctx.clearRect(0, 0, width, height); - - // 绘制网格背景 - const gridSize = 16; - ctx.strokeStyle = 'rgba(0, 0, 0, 0.04)'; - ctx.lineWidth = 0.5; - - for (let x = 0; x <= width; x += gridSize) { - ctx.beginPath(); - ctx.moveTo(x + 0.5, 0); - ctx.lineTo(x + 0.5, height); - ctx.stroke(); - } - - for (let y = 0; y <= height; y += gridSize) { - ctx.beginPath(); - ctx.moveTo(0, y + 0.5); - ctx.lineTo(width, y + 0.5); - ctx.stroke(); - } - - // 绘制装饰性渐变圆圈 - const circles = [ - { x: width * 0.2, y: height * 0.15, radius: 50, opacity: 0.08 }, - { x: width * 0.8, y: height * 0.25, radius: 60, opacity: 0.06 }, - { x: width * 0.75, y: height * 0.75, radius: 40, opacity: 0.07 }, - ]; - - circles.forEach((circle) => { - const gradient = ctx.createRadialGradient( - circle.x, - circle.y, - 0, - circle.x, - circle.y, - circle.radius - ); - gradient.addColorStop(0, `rgba(0, 0, 0, ${circle.opacity})`); - gradient.addColorStop(0.5, `rgba(0, 0, 0, ${circle.opacity * 0.5})`); - gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); - - ctx.beginPath(); - ctx.arc(circle.x, circle.y, circle.radius, 0, Math.PI * 2); - ctx.fillStyle = gradient; - ctx.fill(); - }); - - // 绘制点阵装饰 - ctx.fillStyle = 'rgba(0, 0, 0, 0.02)'; - const dotSpacing = 24; - for (let x = dotSpacing; x < width; x += dotSpacing) { - for (let y = dotSpacing; y < height; y += dotSpacing) { - if (Math.random() > 0.85) { - ctx.beginPath(); - ctx.arc(x, y, 1, 0, Math.PI * 2); - ctx.fill(); - } - } - } - }; - - // 初始绘制 - drawPattern(); - - // 监听窗口大小变化 - const resizeObserver = new ResizeObserver(() => { - drawPattern(); - }); - resizeObserver.observe(canvas); - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - return ( -
- {/* Canvas 背景 */} - - {/* 内容层 - 重新设计的银行卡样式 */} -
- {/* 顶部区域 */} -
-
- {/* 加密圆圈装饰 */} -
- {Array.from({ length: 20 }).map((_, i) => ( - - ))} -
- {/* 余额标签 */} -
- {t('wallet.balance')} -
- {/* 余额金额 */} -
- ${balance.toFixed(2)} -
-
- {/* 钱包图标 */} -
- -
-
- - {/* 中间区域 - 如果有赠送金 */} - {giftBalance > 0 && ( -
-
- {t('wallet.gift')}:{' '} - - ${giftBalance.toFixed(2)} - -
-
- )} - - {/* 底部区域 */} -
-
- {t('wallet.title')} -
- {/* 装饰性元素 - 模拟芯片 */} -
- {Array.from({ length: 6 }).map((_, i) => ( -
- ))} -
-
-
-
- ); -} diff --git a/plugins/pages/billing-center/src/components/wallet-skeleton.tsx b/plugins/pages/billing-center/src/components/wallet-skeleton.tsx deleted file mode 100644 index cd2504d5..00000000 --- a/plugins/pages/billing-center/src/components/wallet-skeleton.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -export function WalletSkeleton() { - return ( -
-
- {/* 左侧内容 - 2/3 宽度 */} -
- {/* 当前套餐信息 */} -
-
-
-
-
- - -
- -
-
- - -
-
- - -
-
-
-
- - -
-
-
-
- - {/* 资源使用情况 */} -
- -
- {Array.from({ length: 3 }).map((_, index) => ( -
- {/* 管理链接 - 右上角 */} -
- -
-
- {/* 图标和名称 */} -
- - -
- - {/* 圆形进度条 */} -
- -
- -
-
- - {/* 使用量信息 */} - -
-
- ))} -
-
- - {/* 自动续订服务 */} -
- -
- {/* 空状态 skeleton */} -
- - - -
-
-
-
- - {/* 右侧内容 - 1/3 宽度 */} -
- {/* 钱包信息 */} -
-
- - -
- {/* 银行卡样式 skeleton - 完全匹配 WalletCard 结构 */} -
- {/* Canvas 背景占位 */} -
- {/* 内容层 */} -
- {/* 顶部区域 */} -
-
- {/* 加密圆圈装饰 */} -
- {Array.from({ length: 20 }).map((_, i) => ( - - ))} -
- {/* 余额标签 */} - - {/* 余额金额 */} - -
- {/* 钱包图标 */} - -
- - {/* 底部区域 */} -
- - {/* 装饰性元素 - 模拟芯片 */} -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
-
-
- -
- - {/* 自动续订组合支付 */} -
-
- - -
- -
- - {/* 月结费用(条件渲染,可能不显示) */} -
- - - - -
-
-
-
- ); -} diff --git a/plugins/pages/billing-center/src/components/wallet-tab.tsx b/plugins/pages/billing-center/src/components/wallet-tab.tsx deleted file mode 100644 index f8bf0fd0..00000000 --- a/plugins/pages/billing-center/src/components/wallet-tab.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; -import { RefreshCw } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { WalletCard } from './wallet-card'; -import { CurrentPlanCard } from './current-plan-card'; -import { ResourceUsageCard } from './resource-usage-card'; -import { AutoRenewalServices } from './auto-renewal-services'; -import { CouponsList } from './coupons-list'; -import { calculateResourceUsages } from '../utils/resource-usage'; -import type { AccountInfo, AutoRenewalService } from '../types'; - -interface WalletTabProps { - account: AccountInfo; - autoRenewalServices: AutoRenewalService[]; - onRefresh: () => void; -} - -/** - * 钱包标签页组件 - */ -export function WalletTab({ - account, - autoRenewalServices, - onRefresh, -}: WalletTabProps) { - const { t } = useTranslation('billing'); - const navigate = useNavigate(); - const resourceUsages = calculateResourceUsages(account, t); - - return ( -
-
- {/* 左侧内容 - 2/3 宽度 */} -
- {/* 当前套餐信息 */} - - - {/* 资源使用情况 */} -
-

{t('resources.title')}

-
- {resourceUsages.map((resource) => ( - - ))} -
-
- - {/* 自动续订服务 */} - -
- - {/* 右侧内容 - 1/3 宽度 */} -
- {/* 钱包信息 */} -
-
-

{t('wallet.title')}

- -
- {/* 银行卡样式 - 使用 WalletCard 组件 */} - - -
- - {/* 分割线 */} -
- - {/* 优惠券列表 */} - - - {/* 月结费用 */} - {account.monthlyBilling > 0 && ( -
-

{t('monthly.title')}

-
- ${account.monthlyBilling.toFixed(2)} -
-
- {t('monthly.basicPackage')}: ${account.monthlyBilling.toFixed(2)} -
- -
- )} -
-
-
- ); -} diff --git a/plugins/pages/billing-center/src/constants.ts b/plugins/pages/billing-center/src/constants.ts deleted file mode 100644 index 020b0284..00000000 --- a/plugins/pages/billing-center/src/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * 每页显示的项目数 - */ -export const ITEMS_PER_PAGE = 10; diff --git a/plugins/pages/billing-center/src/hooks/use-billing-data.ts b/plugins/pages/billing-center/src/hooks/use-billing-data.ts deleted file mode 100644 index fb4065d0..00000000 --- a/plugins/pages/billing-center/src/hooks/use-billing-data.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { useEffect, useState } from 'react'; -import type { AccountInfo, AutoRenewalService } from '../types'; -import { - getAccountInfo, - getAutoRenewalServices, - type AccountInfoResponse, - type AutoRenewalServiceResponse, -} from '../api'; - -interface UseBillingDataReturn { - accountInfo: AccountInfo | null; - autoRenewalServices: AutoRenewalService[]; - loading: boolean; - error: string | null; - refresh: () => Promise; -} - -/** - * 转换后端账户信息为前端格式 - */ -function transformAccountInfo(data: AccountInfoResponse): AccountInfo { - const subscription = data.subscription; - const quota = data.quota; - - return { - planName: subscription ? '已订阅' : '免费', - planId: subscription?.plan_uuid || 'free', - email: data.email, - billingDate: subscription?.next_billing_date || null, - walletBalance: Number(data.wallet_balance), - giftBalance: Number(data.gift_balance), - monthlyBilling: Number(data.monthly_billing), - environmentsUsed: quota.used_environments, - environmentsLimit: quota.max_environments, - teamMembersUsed: quota.used_team_members, - teamMembersLimit: quota.max_team_members, - proxyCount: quota.used_proxies, - proxyLimit: quota.max_proxies, - dailyCreateLimit: 150, // TODO: 如果后端有这些字段,从配额中获取 - dailyOpenLimit: 500, - dailyCreateUsed: 0, - dailyOpenUsed: 0, - }; -} - -/** - * 转换自动续费服务 - */ -function transformAutoRenewalServices( - data: AutoRenewalServiceResponse[] -): AutoRenewalService[] { - return data.map((item) => ({ - id: item.uuid, - serviceName: item.service_name, - renewalPrice: Number(item.renewal_price), - currency: item.currency, - nextBillDate: item.next_bill_date, - })); -} - -/** - * 获取计费数据的 Hook - */ -export function useBillingData(): UseBillingDataReturn { - const [accountInfo, setAccountInfo] = useState(null); - const [autoRenewalServices, setAutoRenewalServices] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchData = async () => { - setLoading(true); - setError(null); - try { - const [accountData, autoRenewalData] = await Promise.all([ - getAccountInfo(), - getAutoRenewalServices(), - ]); - - setAccountInfo(transformAccountInfo(accountData)); - setAutoRenewalServices(transformAutoRenewalServices(autoRenewalData)); - } catch (e) { - setError(e instanceof Error ? e.message : '未知错误'); - setAccountInfo(null); - setAutoRenewalServices([]); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - void fetchData(); - }, []); - - return { - accountInfo, - autoRenewalServices, - loading, - error, - refresh: fetchData, - }; -} diff --git a/plugins/pages/billing-center/src/hooks/use-billing-tabs.ts b/plugins/pages/billing-center/src/hooks/use-billing-tabs.ts deleted file mode 100644 index d871bc81..00000000 --- a/plugins/pages/billing-center/src/hooks/use-billing-tabs.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useState } from 'react'; - -type BillingTab = 'wallet' | 'invoices'; - -interface UseBillingTabsReturn { - activeTab: BillingTab; - setActiveTab: (tab: BillingTab) => void; -} - -/** - * 计费中心 Tab 切换逻辑 Hook - */ -export function useBillingTabs(onTabChange?: (tab: BillingTab) => void): UseBillingTabsReturn { - const [activeTab, setActiveTabState] = useState('wallet'); - - const setActiveTab = (tab: BillingTab) => { - setActiveTabState(tab); - onTabChange?.(tab); - }; - - return { - activeTab, - setActiveTab, - }; -} diff --git a/plugins/pages/billing-center/src/hooks/use-invoice-filters-state.ts b/plugins/pages/billing-center/src/hooks/use-invoice-filters-state.ts deleted file mode 100644 index 90e8056c..00000000 --- a/plugins/pages/billing-center/src/hooks/use-invoice-filters-state.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { InvoiceStatusFilter } from '../types'; - -interface InvoiceFiltersState { - searchQuery: string; - statusFilter: InvoiceStatusFilter; - typeFilter: string; - startDate: string; - endDate: string; -} - -interface UseInvoiceFiltersStateReturn { - filters: InvoiceFiltersState; - setSearchQuery: (value: string) => void; - setStatusFilter: (value: InvoiceStatusFilter) => void; - setTypeFilter: (value: string) => void; - setStartDate: (value: string) => void; - setEndDate: (value: string) => void; - resetFilters: () => void; - updateFilter: (filterType: keyof InvoiceFiltersState, value: string) => void; -} - -/** - * 发票过滤状态管理 Hook - */ -export function useInvoiceFiltersState(onFilterChange?: () => void): UseInvoiceFiltersStateReturn { - const [searchQuery, setSearchQueryState] = useState(''); - const [statusFilter, setStatusFilterState] = useState('all'); - const [typeFilter, setTypeFilterState] = useState(''); - const [startDate, setStartDateState] = useState(''); - const [endDate, setEndDateState] = useState(''); - - const handleFilterChange = useCallback(() => { - onFilterChange?.(); - }, [onFilterChange]); - - const setSearchQuery = useCallback( - (value: string) => { - setSearchQueryState(value); - handleFilterChange(); - }, - [handleFilterChange] - ); - - const setStatusFilter = useCallback( - (value: InvoiceStatusFilter) => { - setStatusFilterState(value); - handleFilterChange(); - }, - [handleFilterChange] - ); - - const setTypeFilter = useCallback( - (value: string) => { - setTypeFilterState(value); - handleFilterChange(); - }, - [handleFilterChange] - ); - - const setStartDate = useCallback( - (value: string) => { - setStartDateState(value); - handleFilterChange(); - }, - [handleFilterChange] - ); - - const setEndDate = useCallback( - (value: string) => { - setEndDateState(value); - handleFilterChange(); - }, - [handleFilterChange] - ); - - const resetFilters = useCallback(() => { - setSearchQueryState(''); - setStatusFilterState('all'); - setTypeFilterState(''); - setStartDateState(''); - setEndDateState(''); - handleFilterChange(); - }, [handleFilterChange]); - - const updateFilter = useCallback( - (filterType: keyof InvoiceFiltersState, value: string) => { - switch (filterType) { - case 'searchQuery': - setSearchQueryState(value); - break; - case 'statusFilter': - setStatusFilterState(value as InvoiceStatusFilter); - break; - case 'typeFilter': - setTypeFilterState(value); - break; - case 'startDate': - setStartDateState(value); - break; - case 'endDate': - setEndDateState(value); - break; - } - handleFilterChange(); - }, - [handleFilterChange] - ); - - return { - filters: { - searchQuery, - statusFilter, - typeFilter, - startDate, - endDate, - }, - setSearchQuery, - setStatusFilter, - setTypeFilter, - setStartDate, - setEndDate, - resetFilters, - updateFilter, - }; -} diff --git a/plugins/pages/billing-center/src/hooks/use-invoice-filters.ts b/plugins/pages/billing-center/src/hooks/use-invoice-filters.ts deleted file mode 100644 index fb8f7341..00000000 --- a/plugins/pages/billing-center/src/hooks/use-invoice-filters.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useMemo } from 'react'; -import type { Invoice, InvoiceStatusFilter } from '../types'; - -interface InvoiceFilters { - searchQuery: string; - statusFilter: InvoiceStatusFilter; - typeFilter: string; - startDate: string; - endDate: string; -} - -/** - * 账单过滤 Hook - */ -export function useInvoiceFilters(invoices: Invoice[], filters: InvoiceFilters): Invoice[] { - return useMemo(() => { - let result = [...invoices]; - - // 搜索筛选 - if (filters.searchQuery.trim()) { - const query = filters.searchQuery.toLowerCase(); - result = result.filter( - (inv) => - inv.type.toLowerCase().includes(query) || - inv.description.toLowerCase().includes(query) || - inv.operator.toLowerCase().includes(query) || - inv.id.toLowerCase().includes(query) - ); - } - - // 状态筛选 - if (filters.statusFilter !== 'all') { - result = result.filter((inv) => inv.status === filters.statusFilter); - } - - // 类型筛选 - if (filters.typeFilter) { - result = result.filter((inv) => inv.type === filters.typeFilter); - } - - // 日期筛选 - if (filters.startDate) { - result = result.filter((inv) => inv.createdAt >= filters.startDate); - } - if (filters.endDate) { - result = result.filter((inv) => inv.createdAt <= filters.endDate + ' 23:59:59'); - } - - return result; - }, [ - invoices, - filters.searchQuery, - filters.statusFilter, - filters.typeFilter, - filters.startDate, - filters.endDate, - ]); -} diff --git a/plugins/pages/billing-center/src/hooks/use-invoice-pagination.ts b/plugins/pages/billing-center/src/hooks/use-invoice-pagination.ts deleted file mode 100644 index a1f8e7e2..00000000 --- a/plugins/pages/billing-center/src/hooks/use-invoice-pagination.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useMemo } from 'react'; -import type { Invoice } from '../types'; -import { ITEMS_PER_PAGE } from '../constants'; - -interface UseInvoicePaginationReturn { - paginatedInvoices: Invoice[]; - totalPages: number; - startIndex: number; - endIndex: number; -} - -/** - * 账单分页 Hook - */ -export function useInvoicePagination( - invoices: Invoice[], - currentPage: number -): UseInvoicePaginationReturn { - const { paginatedInvoices, totalPages, startIndex, endIndex } = useMemo(() => { - const totalPages = Math.max(1, Math.ceil(invoices.length / ITEMS_PER_PAGE)); - const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; - const endIndex = startIndex + ITEMS_PER_PAGE; - const paginatedInvoices = invoices.slice(startIndex, endIndex); - - return { - paginatedInvoices, - totalPages, - startIndex, - endIndex, - }; - }, [invoices, currentPage]); - - return { - paginatedInvoices, - totalPages, - startIndex, - endIndex, - }; -} diff --git a/plugins/pages/billing-center/src/hooks/use-invoice-selection.ts b/plugins/pages/billing-center/src/hooks/use-invoice-selection.ts deleted file mode 100644 index d7d12b84..00000000 --- a/plugins/pages/billing-center/src/hooks/use-invoice-selection.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useState, useMemo } from 'react'; -import type { Invoice } from '../types'; - -interface UseInvoiceSelectionReturn { - selectedInvoiceIds: Set; - allInvoicesSelected: boolean; - someInvoicesSelected: boolean; - selectInvoice: (id: string, selected: boolean) => void; - selectAllInvoices: (invoices: Invoice[], selected: boolean) => void; - clearSelection: () => void; -} - -/** - * 发票选择逻辑 Hook - */ -export function useInvoiceSelection(invoices: Invoice[]): UseInvoiceSelectionReturn { - const [selectedInvoiceIds, setSelectedInvoiceIds] = useState>(new Set()); - - const selectInvoice = (id: string, selected: boolean) => { - setSelectedInvoiceIds((prev) => { - const newSelected = new Set(prev); - if (selected) { - newSelected.add(id); - } else { - newSelected.delete(id); - } - return newSelected; - }); - }; - - const selectAllInvoices = (invoicesToSelect: Invoice[], selected: boolean) => { - if (selected) { - setSelectedInvoiceIds(new Set(invoicesToSelect.map((inv) => inv.id))); - } else { - setSelectedInvoiceIds(new Set()); - } - }; - - const clearSelection = () => { - setSelectedInvoiceIds(new Set()); - }; - - const allInvoicesSelected = useMemo(() => { - return invoices.length > 0 && invoices.every((inv) => selectedInvoiceIds.has(inv.id)); - }, [invoices, selectedInvoiceIds]); - - const someInvoicesSelected = useMemo(() => { - return invoices.some((inv) => selectedInvoiceIds.has(inv.id)); - }, [invoices, selectedInvoiceIds]); - - return { - selectedInvoiceIds, - allInvoicesSelected, - someInvoicesSelected, - selectInvoice, - selectAllInvoices, - clearSelection, - }; -} diff --git a/plugins/pages/billing-center/src/hooks/use-invoice-stats.ts b/plugins/pages/billing-center/src/hooks/use-invoice-stats.ts deleted file mode 100644 index 4fe2272b..00000000 --- a/plugins/pages/billing-center/src/hooks/use-invoice-stats.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useMemo } from 'react'; -import type { Invoice } from '../types'; - -interface InvoiceStats { - total: number; - todayCount: number; - weekCount: number; - todayAmount: number; - weekAmount: number; -} - -/** - * 账单统计数据 Hook - */ -export function useInvoiceStats(invoices: Invoice[]): InvoiceStats { - return useMemo(() => { - const today = new Date().toISOString().slice(0, 10); - const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); - const todayInvoices = invoices.filter((inv) => inv.createdAt.startsWith(today)); - const weekInvoices = invoices.filter((inv) => inv.createdAt >= weekAgo); - const todayAmount = todayInvoices.reduce((sum, inv) => sum + inv.amount, 0); - const weekAmount = weekInvoices.reduce((sum, inv) => sum + inv.amount, 0); - - return { - total: invoices.length, - todayCount: todayInvoices.length, - weekCount: weekInvoices.length, - todayAmount, - weekAmount, - }; - }, [invoices]); -} diff --git a/plugins/pages/billing-center/src/hooks/use-invoices-data.ts b/plugins/pages/billing-center/src/hooks/use-invoices-data.ts deleted file mode 100644 index 5e4dcacd..00000000 --- a/plugins/pages/billing-center/src/hooks/use-invoices-data.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useEffect, useMemo } from 'react'; -import { useInvoices } from './use-invoices'; -import { useInvoiceFilters } from './use-invoice-filters'; -import { useInvoicePagination } from './use-invoice-pagination'; -import { useInvoiceStats } from './use-invoice-stats'; -import { useInvoiceSelection } from './use-invoice-selection'; -import type { InvoiceStatusFilter } from '../types'; - -interface InvoiceFilters { - searchQuery: string; - statusFilter: InvoiceStatusFilter; - typeFilter: string; - startDate: string; - endDate: string; -} - -interface UseInvoicesDataReturn { - invoices: ReturnType; - filteredInvoices: ReturnType; - pagination: ReturnType; - stats: ReturnType; - selection: ReturnType; - invoiceTypes: string[]; -} - -/** - * 整合发票相关数据的 Hook - */ -export function useInvoicesData( - filters: InvoiceFilters, - currentPage: number, - activeTab: 'wallet' | 'invoices' -): UseInvoicesDataReturn { - const invoices = useInvoices(); - - const filteredInvoices = useInvoiceFilters(invoices.invoices, filters); - const pagination = useInvoicePagination(filteredInvoices, currentPage); - const stats = useInvoiceStats(filteredInvoices); - const selection = useInvoiceSelection(pagination.paginatedInvoices); - - const invoiceTypes = useMemo(() => { - const types = new Set(invoices.invoices.map((inv) => inv.type)); - return Array.from(types); - }, [invoices.invoices]); - - return { - invoices, - filteredInvoices, - pagination, - stats, - selection, - invoiceTypes, - }; -} diff --git a/plugins/pages/billing-center/src/hooks/use-invoices.ts b/plugins/pages/billing-center/src/hooks/use-invoices.ts deleted file mode 100644 index 14dc185b..00000000 --- a/plugins/pages/billing-center/src/hooks/use-invoices.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useEffect, useState, useCallback } from 'react'; -import type { Invoice } from '../types'; -import { getInvoices, type InvoiceItem } from '../api'; - -interface UseInvoicesReturn { - invoices: Invoice[]; - loading: boolean; - error: string | null; - refresh: () => Promise; -} - -/** - * 转换后端发票格式为前端格式 - */ -function transformInvoice(item: InvoiceItem): Invoice { - return { - id: item.uuid, - createdAt: item.created_at, - amount: Number(item.amount), - currency: item.currency, - status: item.status as 'paid' | 'pending' | 'failed', - type: item.invoice_type, - description: item.invoice_number, - operator: '', // TODO: 如果后端有操作者字段,从后端获取 - }; -} - -/** - * 获取账单列表的 Hook - */ -export function useInvoices(): UseInvoicesReturn { - const [invoices, setInvoices] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchInvoices = useCallback(async () => { - setLoading(true); - setError(null); - try { - const result = await getInvoices({ - pagination: { - page: 1, - page_size: 1000, // TODO: 如果需要分页,可以改为动态参数 - }, - }); - setInvoices(result.items.map(transformInvoice)); - } catch (e) { - setError(e instanceof Error ? e.message : '未知错误'); - setInvoices([]); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void fetchInvoices(); - }, [fetchInvoices]); - - return { - invoices, - loading, - error, - refresh: fetchInvoices, - }; -} diff --git a/plugins/pages/billing-center/src/i18n/resources.ts b/plugins/pages/billing-center/src/i18n/resources.ts deleted file mode 100644 index 1aa9650d..00000000 --- a/plugins/pages/billing-center/src/i18n/resources.ts +++ /dev/null @@ -1,275 +0,0 @@ -export const billingResources = { - 'zh-CN': { - title: '费用中心', - subtitle: '管理您的账户和账单', - loading: '加载中...', - error: '加载失败:{{error}}', - banner: { - current: '当前:{{plan}}({{limit}}环境)', - usage: '用量:{{used}}/{{total}}', - }, - currentPlan: { - title: '当前套餐', - free: '免费版', - upgrade: '升级套餐', - change: '更换套餐', - purchase: '购买套餐', - nextBilling: '下次账单日期', - windows: '窗口', - premiumPrompt: '成为付费用户,显示高级功能', - }, - resources: { - title: '资源使用情况', - environments: '浏览器环境', - teamMembers: '团队成员', - proxies: '代理IP', - unit: '个', - manage: '管理', - warningHigh: '使用率过高,建议升级套餐', - warningMedium: '使用率较高,请注意', - }, - dailyLimits: { - title: '每日使用限制', - create: '创建次数', - open: '打开次数', - }, - autoRenewal: { - title: '自动续订服务', - serviceDetails: '服务详情', - renewalPrice: '续费价格', - nextBillDate: '下一次账单日期', - noServices: '您目前没有将服务设置为自动续订', - setup: '设置自动续订', - }, - tabs: { - wallet: '钱包', - invoices: '账单', - }, - invoices: { - title: '账单记录', - type: '类型', - amount: '金额', - description: '描述', - status: '状态', - date: '日期', - noData: '暂无账单记录', - statusPaid: '已支付', - statusPending: '待支付', - statusFailed: '支付失败', - searchPlaceholder: '搜索账单...', - refresh: '刷新', - loading: '加载中...', - error: '加载失败:{{error}}', - filters: { - allStatuses: '全部状态', - allTypes: '全部类型', - }, - stats: { - total: '总计', - today: '今日', - week: '本周', - todayAmount: '今日金额', - weekAmount: '本周金额', - }, - pagination: { - showing: '显示 {{start}}-{{end}} 条,共 {{total}} 条', - pageInfo: '第 {{current}} 页,共 {{total}} 页', - previous: '上一页', - next: '下一页', - }, - table: { - type: '类型', - amount: '金额', - description: '描述', - status: '状态', - operator: '操作者', - date: '日期', - }, - }, - wallet: { - title: '钱包', - balance: '余额', - gift: '赠送金', - recharge: '充值', - promotionText: '参与', - promotionLink: '推广计划', - promotionSuffix: '获得奖励', - autoRenewal: { - title: '启用自动续订组合支付', - description: '当钱包余额不足以自动续订时,自动用卡片组合支付。(仅默认卡片会被用于组合支付)', - }, - cards: { - title: '卡片信息', - addNew: '新增', - noCards: '暂无绑定卡片', - }, - }, - account: { - title: '账户信息', - email: '邮箱', - billingDate: '月结日期', - }, - monthly: { - title: '月结费用', - basicPackage: '基础套餐', - upgrade: '升级套餐', - }, - promotion: { - title: '限时优惠', - description: '首充送 20% 额外余额,立即充值享受优惠', - action: '立即充值', - }, - coupons: { - title: '我的优惠券', - noCoupons: '暂无可用优惠券', - noCouponsDescription: '您目前还没有优惠券,前往套餐页面查看可用优惠', - viewPlans: '查看套餐', - viewAll: '查看全部', - close: '关闭', - searchPlaceholder: '搜索优惠券代码...', - noMatch: '未找到匹配的优惠券', - dialogDescription: '查看和管理您的所有优惠券', - status: { - unused: '未使用', - used: '已使用', - expired: '已过期', - }, - minAmount: '满{{amount}}可用', - expiresAt: '有效期至 {{date}}', - unknown: '优惠券', - }, - }, - 'en-US': { - title: 'Billing Center', - subtitle: 'Manage your account and billing', - loading: 'Loading...', - error: 'Failed to load: {{error}}', - banner: { - current: 'Current:{{plan}}({{limit}} environments)', - usage: 'Usage:{{used}}/{{total}}', - }, - currentPlan: { - title: 'Current Plan', - free: 'Free', - upgrade: 'Upgrade Plan', - change: 'Change Plan', - purchase: 'Purchase Package', - nextBilling: 'Next Billing Date', - windows: 'Windows', - premiumPrompt: 'Become a paid user to unlock premium features', - }, - resources: { - title: 'Resource Usage', - environments: 'Browser Environments', - teamMembers: 'Team Members', - proxies: 'Proxy IPs', - unit: '', - manage: 'Manage', - warningHigh: 'Usage is too high, consider upgrading', - warningMedium: 'Usage is high, please note', - }, - dailyLimits: { - title: 'Daily Usage Limits', - create: 'Creation Count', - open: 'Open Count', - }, - autoRenewal: { - title: 'Automatic Renewal Services', - serviceDetails: 'Service Details', - renewalPrice: 'Renewal Price', - nextBillDate: 'Next Bill Date', - noServices: 'You currently have no services set for automatic renewal', - setup: 'Setup Auto-renewal', - }, - tabs: { - wallet: 'Wallet', - invoices: 'Invoices', - }, - invoices: { - title: 'Invoice Records', - type: 'Type', - amount: 'Amount', - description: 'Description', - status: 'Status', - date: 'Date', - noData: 'No invoice records', - statusPaid: 'Paid', - statusPending: 'Pending', - statusFailed: 'Failed', - stats: { - total: 'Total', - today: 'Today', - week: 'This Week', - todayAmount: 'Today Amount', - weekAmount: 'Week Amount', - }, - pagination: { - showing: 'Showing {{start}}-{{end}} of {{total}}', - pageInfo: 'Page {{current}} / {{total}}', - previous: 'Previous', - next: 'Next', - }, - table: { - type: 'Type', - amount: 'Amount', - description: 'Description', - status: 'Status', - operator: 'Operator', - date: 'Date', - }, - }, - wallet: { - title: 'Wallet', - balance: 'Balance', - gift: 'Gift Amount', - recharge: 'Recharge', - promotionText: 'Participate in', - promotionLink: 'Promotion Plan', - promotionSuffix: 'to earn rewards', - autoRenewal: { - title: 'Enable Automatic Renewal Combined Payment', - description: - 'When the wallet balance is insufficient for automatic renewal, automatically use card combination payment. (Only the default card will be used for combined payment)', - }, - cards: { - title: 'Card Information', - addNew: 'Add New', - noCards: 'No bound cards yet', - }, - }, - account: { - title: 'Account Information', - email: 'Email', - billingDate: 'Monthly Billing Date', - }, - monthly: { - title: 'Monthly Billing', - basicPackage: 'Basic Package', - upgrade: 'Upgrade Package', - }, - promotion: { - title: 'Limited Offer', - description: 'Get 20% extra balance on first recharge', - action: 'Recharge Now', - }, - coupons: { - title: 'My Coupons', - noCoupons: 'No available coupons', - noCouponsDescription: 'You currently have no coupons. Visit the plans page to see available offers', - viewPlans: 'View Plans', - viewAll: 'View All', - close: 'Close', - searchPlaceholder: 'Search coupon code...', - noMatch: 'No matching coupons found', - dialogDescription: 'View and manage all your coupons', - status: { - unused: 'Unused', - used: 'Used', - expired: 'Expired', - }, - minAmount: 'Min {{amount}}', - expiresAt: 'Expires {{date}}', - unknown: 'Coupon', - }, - }, -} as const; diff --git a/plugins/pages/billing-center/src/index.tsx b/plugins/pages/billing-center/src/index.tsx deleted file mode 100644 index d484e3d8..00000000 --- a/plugins/pages/billing-center/src/index.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { extensionRegistry } from '@slotkitjs/core'; -import { useTranslation } from 'react-i18next'; -import { billingResources } from './i18n/resources'; -import { useState } from 'react'; -import { WalletSkeleton } from './components/wallet-skeleton'; -import { InvoicesSkeleton } from './components/invoices-skeleton'; -import { WalletTab } from './components/wallet-tab'; -import { InvoicesTab } from './components/invoices-tab'; -import { BillingTabs } from './components/billing-tabs'; -import { useBillingData } from './hooks/use-billing-data'; -import { useBillingTabs } from './hooks/use-billing-tabs'; -import { useInvoiceFiltersState } from './hooks/use-invoice-filters-state'; -import { useInvoicesData } from './hooks/use-invoices-data'; - -const BillingCenterPage: React.FC = () => { - const { t } = useTranslation('billing'); - const [currentPage, setCurrentPage] = useState(1); - - // Tab 管理 - const { activeTab, setActiveTab } = useBillingTabs(); - - // 数据获取 - const { accountInfo, autoRenewalServices, loading, error, refresh } = useBillingData(); - - // 过滤状态管理 - const filtersState = useInvoiceFiltersState(() => { - setCurrentPage(1); - }); - - // 发票数据(整合了过滤、分页、统计、选择等逻辑) - const invoicesData = useInvoicesData(filtersState.filters, currentPage, activeTab); - - return ( -
- {/* 顶部区域 - 包含 Tab 导航 */} - - -
- {error &&
{t('error', { error })}
} -
- {/* 钱包标签页 */} - {activeTab === 'wallet' && - (loading || !accountInfo ? ( - - ) : ( - - ))} - - {/* 账单标签页 */} - {activeTab === 'invoices' && - (invoicesData.invoices.loading ? ( - - ) : ( - - ))} -
-
-
- ); -}; - -// 在模块加载时贡献路由 -try { - extensionRegistry.contribute('routes', { - contributorId: 'billing-center', - value: { - path: '/billing', - Component: BillingCenterPage, - }, - priority: 10, - }); - console.log('[billing-center] Route contributed at module load: /billing'); -} catch (error) { - console.warn('[billing-center] Failed to contribute route at module load:', error); -} - -try { - extensionRegistry.contribute('i18n:resources', { - contributorId: 'billing-center', - value: { - namespace: 'billing', - resources: billingResources, - }, - priority: 10, - }); -} catch (error) { - console.warn('[billing-center] Failed to contribute i18n resources:', error); -} - -const billingCenterPlugin = { - id: 'billing-center', - name: 'Billing Center', - version: '1.0.0', - component: BillingCenterPage, - slots: [], -}; - -export default billingCenterPlugin; diff --git a/plugins/pages/billing-center/src/types/index.ts b/plugins/pages/billing-center/src/types/index.ts deleted file mode 100644 index 30221be7..00000000 --- a/plugins/pages/billing-center/src/types/index.ts +++ /dev/null @@ -1,103 +0,0 @@ -export interface BillingPlan { - id: string; - name: string; - pricePerMonth: number; - currency: string; - environmentsLimit: number; - description: string; -} - -export interface Invoice { - id: string; - createdAt: string; - amount: number; - currency: string; - status: 'paid' | 'pending' | 'failed'; - type: string; - description: string; - operator: string; -} - -export interface AccountInfo { - planName: string; - planId: string; - email: string; - billingDate: string | null; - walletBalance: number; - giftBalance: number; - monthlyBilling: number; - environmentsUsed: number; - environmentsLimit: number; - teamMembersUsed: number; - teamMembersLimit: number; - proxyCount: number; - proxyLimit: number; - dailyCreateLimit: number; - dailyOpenLimit: number; - dailyCreateUsed: number; - dailyOpenUsed: number; -} - -// 后端返回的账户信息格式 -export interface AccountInfoResponse { - email: string; - wallet_balance: number; - gift_balance: number; - currency: string; - subscription: SubscriptionResponse | null; - quota: QuotaResponse; - monthly_billing: number; -} - -export interface SubscriptionResponse { - id: number; - uuid: string; - workspace_uuid: string; - user_uuid: string; - plan_uuid: string; - billing_period: string; - price: number; - currency: string; - started_at: string; - expires_at: string; - next_billing_date: string | null; - auto_renew: boolean | null; - status: string; - cancelled_at: string | null; - created_at: string; - updated_at: string; -} - -export interface QuotaResponse { - workspace_uuid: string; - max_environments: number; - used_environments: number; - max_team_members: number; - used_team_members: number; - max_proxies: number; - used_proxies: number; - max_rpa_tasks: number; - used_rpa_tasks: number; - created_at: string; - updated_at: string; -} - -export interface AutoRenewalService { - id: string; - serviceName: string; - renewalPrice: number; - currency: string; - nextBillDate: string; -} - -export interface ResourceUsage { - name: string; - used: number; - limit: number; - unit: string; - icon: React.ComponentType<{ className?: string }>; - color: string; - link?: string; -} - -export type InvoiceStatusFilter = 'all' | 'paid' | 'pending' | 'failed'; diff --git a/plugins/pages/billing-center/src/utils/resource-usage.ts b/plugins/pages/billing-center/src/utils/resource-usage.ts deleted file mode 100644 index 92ae085d..00000000 --- a/plugins/pages/billing-center/src/utils/resource-usage.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { ResourceUsage } from '../types'; -import { Globe, Users } from 'lucide-react'; -import type { TFunction } from 'i18next'; -import type { AccountInfo } from '../types'; - -/** - * 计算资源使用情况 - */ -export function calculateResourceUsages( - account: AccountInfo | null, - t: TFunction<'billing'> -): ResourceUsage[] { - if (!account) return []; - - return [ - { - name: t('resources.environments'), - used: account.environmentsUsed, - limit: account.environmentsLimit, - unit: t('resources.unit'), - icon: Globe, - color: 'text-muted-foreground', - link: '/', - }, - { - name: t('resources.teamMembers'), - used: account.teamMembersUsed, - limit: account.teamMembersLimit, - unit: t('resources.unit'), - icon: Users, - color: 'text-muted-foreground', - link: '/team', - }, - { - name: t('resources.proxies'), - used: account.proxyCount, - limit: account.proxyLimit, - unit: t('resources.unit'), - icon: Globe, - color: 'text-muted-foreground', - link: '/proxy', - }, - ]; -} - -/** - * 计算使用百分比 - */ -export function getUsagePercentage(used: number, limit: number): number { - if (limit === 0) return 0; - return Math.min((used / limit) * 100, 100); -} - -/** - * 获取使用状态样式 - */ -export function getUsageStatus(percentage: number): { color: string; bg: string } { - if (percentage >= 90) return { color: 'text-destructive', bg: 'bg-destructive/10' }; - if (percentage >= 70) return { color: 'text-warning', bg: 'bg-warning/10' }; - return { color: 'text-primary', bg: 'bg-primary/10' }; -} diff --git a/plugins/pages/billing-center/tsconfig.json b/plugins/pages/billing-center/tsconfig.json deleted file mode 100644 index f5b67230..00000000 --- a/plugins/pages/billing-center/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "jsx": "react-jsx" - }, - "include": ["src/**/*"] -} diff --git a/plugins/pages/browser-extensions/src/api/index.ts b/plugins/pages/browser-extensions/src/api/index.ts index 09e1231e..5e7af731 100644 --- a/plugins/pages/browser-extensions/src/api/index.ts +++ b/plugins/pages/browser-extensions/src/api/index.ts @@ -1,52 +1,5 @@ -import { post, isSuccess } from '@/lib/request'; import { invoke } from '@/lib/tauri'; -// API 端点配置 -export const API_ENDPOINTS = { - // 扩展市场 - LIST_EXTENSIONS: 'extensions/list', - EXTENSION_DETAIL: 'extensions/detail', - CATEGORIES: 'extensions/categories', - // 已安装扩展 - INSTALLED: 'extensions/installed', - // 安装/卸载/更新 - INSTALL: 'extensions/install', - UNINSTALL: 'extensions/uninstall', - UPDATE: 'extensions/update', - BATCH_UPDATE: 'extensions/batch-update', - // 禁用/启用 - DISABLE: 'extensions/disable', - ENABLE: 'extensions/enable', -} as const; - -/** - * 扩展 DTO(后端返回格式) - */ -export interface ExtensionDto { - id: number; - uuid: string; - extension_id: string; - name: string; - description?: string; - version: string; - category?: string; - browser: string; - developer?: string; - homepage?: string; - icon_url?: string; - download_url?: string; - file_size?: number; - downloads_count?: number; - rating?: string | number; - permissions?: string[]; - status: string; - created_at: string; - updated_at: string; -} - -/** - * 扩展(前端格式) - */ export interface Extension { id: string; extensionId: string; @@ -56,57 +9,19 @@ export interface Extension { version: string; category?: string; browser: string; - source: 'remote' | 'local'; + source: 'local'; author?: string; homepage?: string; icon?: string; - downloadUrl?: string; - managedCrxPath?: string; fileSize?: number; downloads?: number; permissions?: string[]; - status: 'installed' | 'available' | 'update' | 'disabled' | 'active'; + status: 'available' | 'disabled' | 'active'; rating?: number; updatedAt?: string; createdAt?: string; hash?: string; - scope?: 'user' | 'team' | 'group-personal' | 'group-team' | 'local'; // 安装范围 - groups?: Array<{ - uuid: string; - name: string; - }>; // 关联的分组列表(包含 uuid 和 name) -} - -/** - * 转换 DTO 到前端格式 - */ -function transformExtensionDto(dto: ExtensionDto): Extension { - return { - id: dto.uuid, - extensionId: dto.extension_id, - source: 'remote', - name: dto.name, - description: dto.description || '', - version: dto.version, - category: dto.category as Extension['category'], - browser: dto.browser, - author: dto.developer, - homepage: dto.homepage, - icon: dto.icon_url, - downloadUrl: dto.download_url, - fileSize: dto.file_size, - downloads: dto.downloads_count, - permissions: dto.permissions, - status: (dto.status as Extension['status']) || 'available', - rating: dto.rating - ? typeof dto.rating === 'string' - ? parseFloat(dto.rating) - : dto.rating - : undefined, - updatedAt: dto.updated_at, - createdAt: dto.created_at, - hash: undefined, - }; + scope?: 'local'; } export interface LocalExtensionDto { @@ -153,367 +68,21 @@ function transformLocalExtensionDto(dto: LocalExtensionDto): Extension { author: dto.author, homepage: dto.homepage, icon: dto.iconUrl, - downloadUrl: undefined, fileSize: dto.fileSize, downloads: dto.downloadsCount, permissions: dto.permissions, - status: - dto.status === 'active' ? 'active' : dto.status === 'disabled' ? 'disabled' : 'available', + status: dto.status, rating: - dto.rating !== undefined - ? typeof dto.rating === 'string' - ? parseFloat(dto.rating) - : dto.rating - : undefined, + dto.rating === undefined + ? undefined + : typeof dto.rating === 'string' + ? Number(dto.rating) + : dto.rating, updatedAt: dto.updatedAt, createdAt: dto.importedAt, hash: dto.hash, scope: dto.status === 'available' ? undefined : 'local', - groups: undefined, - }; -} - -export interface ListExtensionsResponse { - items: Extension[]; - total: number; - page: number; - page_size: number; -} - -/** - * 获取扩展市场列表 - */ -export async function listExtensions(params?: { - category?: string; - search?: string; - sort_by?: 'downloads' | 'rating' | 'name' | 'newest'; - sort_order?: 'asc' | 'desc'; - page?: number; - page_size?: number; -}): Promise { - const result = await post<{ - items: ExtensionDto[]; - total: number; - page: number; - page_size: number; - }>(API_ENDPOINTS.LIST_EXTENSIONS, { - page: params?.page || 1, - page_size: params?.page_size || 12, - filters: { - category: params?.category, - keyword: params?.search, - sort_by: params?.sort_by, - sort_order: params?.sort_order, - }, - }); - if (!isSuccess(result)) { - throw new Error(result.message || '获取扩展列表失败'); - } - return { - items: (result.data?.items || []).map(transformExtensionDto), - total: result.data?.total || 0, - page: result.data?.page || params?.page || 1, - page_size: result.data?.page_size || params?.page_size || 12, - }; -} - -/** - * 获取扩展详情 - */ -export async function getExtensionDetail(extensionId: string): Promise { - const result = await post(API_ENDPOINTS.EXTENSION_DETAIL, { - extension_id: extensionId, - }); - if (!isSuccess(result)) { - throw new Error(result.message || '获取扩展详情失败'); - } - return transformExtensionDto(result.data!); -} - -/** - * 已安装扩展项(后端返回格式,包含完整扩展详情) - */ -export interface InstalledExtensionItemDto { - extension_id: string; - name: string; - version: string; - installed_version: string; - has_update: boolean; - status: string; - installed_at: string; - homepage?: string; - icon_url?: string; - team_uuid?: string; - scope: 'user' | 'team' | 'group-personal' | 'group-team'; - // 完整扩展详情字段 - description?: string; - category?: string; - browser?: string; - developer?: string; - downloads_count?: number; - rating?: string | number; - permissions?: string[]; - file_size?: number; - updated_at?: string; - groups?: Array<{ - uuid: string; - name: string; - }>; -} - -/** - * 已安装扩展响应(后端返回格式) - */ -export interface InstalledExtensionsResponseDto { - user_extensions: InstalledExtensionItemDto[]; - team_extensions: InstalledExtensionItemDto[]; -} - -/** - * 获取已安装扩展列表 - * - * @param scope - 范围过滤:'all' | 'user' | 'team' - */ -export async function listInstalledExtensions( - scope: 'all' | 'user' | 'team' = 'all' -): Promise { - const result = await post(API_ENDPOINTS.INSTALLED, { - scope: scope === 'all' ? 'all' : scope === 'user' ? 'user' : 'team', - }); - if (!isSuccess(result)) { - throw new Error(result.message || '获取已安装扩展失败'); - } - - const data = result.data; - if (!data) { - return []; - } - - // 合并用户和团队的已安装扩展 - const allInstalled: InstalledExtensionItemDto[] = [ - ...(data.user_extensions || []), - ...(data.team_extensions || []), - ]; - - // 去重(同一个扩展可能在用户和团队中都存在) - const uniqueExtensions = new Map(); - for (const item of allInstalled) { - if (!uniqueExtensions.has(item.extension_id)) { - uniqueExtensions.set(item.extension_id, item); - } - } - - // 直接使用后端返回的完整信息,无需再次调用详情接口 - const extensions: Extension[] = []; - for (const installedItem of Array.from(uniqueExtensions.values())) { - // 根据 status 和 has_update 设置状态 - let status: Extension['status']; - if (installedItem.status === 'disabled') { - status = 'disabled'; - } else if (installedItem.has_update) { - status = 'update'; - } else if (installedItem.status === 'active') { - status = 'active'; - } else { - status = 'installed'; - } - - // 转换评分 - const rating = installedItem.rating - ? typeof installedItem.rating === 'string' - ? parseFloat(installedItem.rating) - : installedItem.rating - : undefined; - - extensions.push({ - id: installedItem.extension_id, // 使用 extension_id 作为业务 ID - extensionId: installedItem.extension_id, - source: 'remote', - name: installedItem.name, - description: installedItem.description || '', - version: installedItem.installed_version, // 使用已安装的版本号 - category: installedItem.category as Extension['category'], - browser: installedItem.browser || 'chrome', - author: installedItem.developer, - homepage: installedItem.homepage, - icon: installedItem.icon_url, - downloadUrl: undefined, // 已安装扩展不需要下载 URL - fileSize: installedItem.file_size, - downloads: installedItem.downloads_count, - permissions: installedItem.permissions as string[] | undefined, - status, - rating, - updatedAt: installedItem.updated_at, - createdAt: undefined, // 已安装扩展不需要创建时间 - hash: undefined, - scope: installedItem.scope, // 保留 scope 信息 - groups: installedItem.groups, // 关联的分组列表(包含 uuid 和 name) - }); - } - - if (scope !== 'team') { - const localExtensions = await listLocalExtensions(); - extensions.push( - ...localExtensions - .filter((item) => item.status === 'active' || item.status === 'disabled') - .map((item) => ({ - ...item, - status: item.status, - scope: 'local' as const, - })) - ); - } - - return extensions; -} - -/** - * 安装扩展 - */ -export async function installExtension(data: { - extension_id: string; - group_ids?: string[]; - for_team?: boolean; - is_team_shared?: boolean; -}): Promise { - // 确定 target_type - let target_type: string; - let group_ids: string[] | undefined; - let is_team_shared: boolean | undefined; - - if (data.for_team) { - target_type = 'team'; - group_ids = undefined; - is_team_shared = undefined; - } else if (data.group_ids && data.group_ids.length > 0) { - target_type = 'group'; - group_ids = data.group_ids; // 即使只有一个分组也传入数组 - is_team_shared = data.is_team_shared; // 分组插件支持团队共享 - } else { - target_type = 'user'; - group_ids = undefined; - is_team_shared = undefined; - } - - const result = await post(API_ENDPOINTS.INSTALL, { - extension_id: data.extension_id, - target_type, - group_ids, - is_team_shared, - }); - if (!isSuccess(result)) { - throw new Error(result.message || '安装扩展失败'); - } -} - -/** - * 卸载扩展 - */ -export async function uninstallExtension( - extensionId: string, - targetType?: 'user' | 'team' | 'group', - targetUuid?: string -): Promise { - const payload: { - extension_id: string; - target_type?: string; - target_uuid?: string; - } = { - extension_id: extensionId, }; - - if (targetType) { - payload.target_type = targetType; - } - - if (targetUuid) { - payload.target_uuid = targetUuid; - } - - const result = await post(API_ENDPOINTS.UNINSTALL, payload); - if (!isSuccess(result)) { - throw new Error(result.message || '卸载扩展失败'); - } -} - -/** - * 更新扩展 - */ -export async function updateExtension(extensionId: string): Promise { - const result = await post(API_ENDPOINTS.UPDATE, { extension_id: extensionId }); - if (!isSuccess(result)) { - throw new Error(result.message || '更新扩展失败'); - } - return transformExtensionDto(result.data!); -} - -/** - * 批量更新扩展 - */ -export async function batchUpdateExtensions(extensionIds: string[]): Promise { - const result = await post(API_ENDPOINTS.BATCH_UPDATE, { extension_ids: extensionIds }); - if (!isSuccess(result)) { - throw new Error(result.message || '批量更新失败'); - } -} - -/** - * 获取扩展分类列表 - */ -export async function getCategories(): Promise { - const result = await post<{ categories: string[] }>(API_ENDPOINTS.CATEGORIES, {}); - if (!isSuccess(result)) { - throw new Error(result.message || '获取分类列表失败'); - } - return result.data?.categories || []; -} - -/** - * 分组项类型 - */ -export interface GroupItem { - uuid: string; - name: string; - description?: string; -} - -/** - * 获取分组列表 - */ -export async function listGroups(): Promise { - const result = await post('groups/list', {}); - if (!isSuccess(result)) { - throw new Error(result.message || '获取分组列表失败'); - } - return (result.data || []).map((dto: any) => ({ - uuid: dto.uuid || '', - name: dto.name || '', - description: dto.description, - })); -} - -/** - * 禁用扩展(用户级别) - */ -export async function disableExtension(extensionId: string): Promise { - const result = await post(API_ENDPOINTS.DISABLE, { - extension_id: extensionId, - }); - if (!isSuccess(result)) { - throw new Error(result.message || '禁用扩展失败'); - } -} - -/** - * 启用扩展(用户级别) - */ -export async function enableExtension(extensionId: string): Promise { - const result = await post(API_ENDPOINTS.ENABLE, { - extension_id: extensionId, - }); - if (!isSuccess(result)) { - throw new Error(result.message || '启用扩展失败'); - } } export async function listLocalExtensions(): Promise { @@ -522,18 +91,14 @@ export async function listLocalExtensions(): Promise { } export async function importLocalExtensionCrx(path: string): Promise { - const result = await invoke('import_local_extension_crx', { - path, - }); + const result = await invoke('import_local_extension_crx', { path }); return transformLocalImportResult(result); } export async function importLocalExtensionStoreUrl( storeUrl: string ): Promise { - const result = await invoke('import_local_extension_store_url', { - storeUrl, - }); + const result = await invoke('import_local_extension_store_url', { storeUrl }); return transformLocalImportResult(result); } @@ -549,36 +114,20 @@ function transformLocalImportResult(dto: LocalExtensionDto): LocalExtensionImpor }; } -export async function installLocalExtension(recordId: string): Promise { - const result = await invoke('install_local_extension', { - recordId, - }); +async function mutateLocalExtension(command: string, recordId: string): Promise { + const result = await invoke(command, { recordId }); return transformLocalExtensionDto(result); } -export async function uninstallLocalExtension(recordId: string): Promise { - const result = await invoke('uninstall_local_extension', { - recordId, - }); - return transformLocalExtensionDto(result); -} +export const installLocalExtension = (recordId: string) => + mutateLocalExtension('install_local_extension', recordId); +export const uninstallLocalExtension = (recordId: string) => + mutateLocalExtension('uninstall_local_extension', recordId); +export const disableLocalExtension = (recordId: string) => + mutateLocalExtension('disable_local_extension', recordId); +export const enableLocalExtension = (recordId: string) => + mutateLocalExtension('enable_local_extension', recordId); export async function removeLocalExtension(recordId: string): Promise { - await invoke('remove_local_extension', { - recordId, - }); -} - -export async function disableLocalExtension(recordId: string): Promise { - const result = await invoke('disable_local_extension', { - recordId, - }); - return transformLocalExtensionDto(result); -} - -export async function enableLocalExtension(recordId: string): Promise { - const result = await invoke('enable_local_extension', { - recordId, - }); - return transformLocalExtensionDto(result); + await invoke('remove_local_extension', { recordId }); } diff --git a/plugins/pages/browser-extensions/src/components/extension-batch-actions.tsx b/plugins/pages/browser-extensions/src/components/extension-batch-actions.tsx deleted file mode 100644 index 8e8034e8..00000000 --- a/plugins/pages/browser-extensions/src/components/extension-batch-actions.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useTranslation } from 'react-i18next'; - -interface ExtensionBatchActionsProps { - selectedCount: number; - onBatchInstall: () => void; - onBatchUpdate: () => void; - onBatchUninstall: () => void; - onClearSelection: () => void; -} - -export function ExtensionBatchActions({ - selectedCount, - onBatchInstall, - onBatchUpdate, - onBatchUninstall, - onClearSelection, -}: ExtensionBatchActionsProps) { - const { t } = useTranslation('extensions'); - - if (selectedCount === 0) return null; - - return ( -
-
- - {t('batch.selected')}: - - - {selectedCount.toString().padStart(2, '0')} - -
-
- - - -
- -
- ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-batch-uninstall-dialog.tsx b/plugins/pages/browser-extensions/src/components/extension-batch-uninstall-dialog.tsx deleted file mode 100644 index bdb2140c..00000000 --- a/plugins/pages/browser-extensions/src/components/extension-batch-uninstall-dialog.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { AlertTriangle } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; - -interface ExtensionBatchUninstallDialogProps { - open: boolean; - count: number; - onOpenChange: (open: boolean) => void; - onConfirm: () => void; -} - -/** - * 批量卸载对话框组件 - */ -export function ExtensionBatchUninstallDialog({ - open, - count, - onOpenChange, - onConfirm, -}: ExtensionBatchUninstallDialogProps) { - const { t } = useTranslation('extensions'); - - return ( - - - - - - {t('dialog.batchUninstall.title')} - - - {t('dialog.batchUninstall.description', { count })} - - - - - - {t('dialog.batchUninstall.warningTitle')} - - - {t('dialog.batchUninstall.warningDescription')} - - - - - {t('dialog.batchUninstall.cancel')} - - - {t('dialog.batchUninstall.confirm')} - - - - - ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-detail-dialog.tsx b/plugins/pages/browser-extensions/src/components/extension-detail-dialog.tsx index 3c458e56..8520ba49 100644 --- a/plugins/pages/browser-extensions/src/components/extension-detail-dialog.tsx +++ b/plugins/pages/browser-extensions/src/components/extension-detail-dialog.tsx @@ -1,11 +1,7 @@ -import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Loader2, Info, Package, Shield, Download, Star, Calendar } from 'lucide-react'; +import { Info } from 'lucide-react'; import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; import { Button } from '@/components/ui/button'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { getExtensionDetail } from '../api'; -import type { Extension } from '../api'; import type { ExtensionItem } from '../types'; import { ExtensionIcon } from './extension-icon'; @@ -15,298 +11,41 @@ interface ExtensionDetailDialogProps { onOpenChange: (open: boolean) => void; } -/** - * 扩展详情对话框组件 - */ export function ExtensionDetailDialog({ open, extension, onOpenChange, }: ExtensionDetailDialogProps) { const { t } = useTranslation('extensions'); - const knownCategories = new Set([ - 'automation', - 'security', - 'productivity', - 'tools', - 'media', - 'social', - ]); - const [remoteState, setRemoteState] = useState<{ - key: string | null; - detail: Extension | null; - error: string | null; - }>({ - key: null, - detail: null, - error: null, - }); - - const remoteExtensionKey = - open && extension && extension.source !== 'local' - ? extension.extensionId || extension.id - : null; - - const localExtensionDetail = - open && extension?.source === 'local' - ? { - id: extension.id, - recordId: extension.recordId, - extensionId: extension.extensionId || extension.id, - source: 'local' as const, - name: extension.name, - description: extension.description, - version: extension.version, - category: extension.category, - browser: extension.browser, - author: extension.author, - homepage: extension.homepage, - icon: extension.icon, - downloadUrl: undefined, - fileSize: extension.fileSize, - downloads: extension.downloads, - permissions: extension.permissions, - status: extension.status, - rating: extension.rating, - updatedAt: extension.updatedAt, - createdAt: extension.createdAt, - hash: extension.hash, - scope: extension.scope, - groups: extension.groups, - } - : null; - - const hasMatchingRemoteDetail = - remoteExtensionKey !== null && - remoteState.detail !== null && - remoteState.key === remoteExtensionKey; - const hasMatchingRemoteError = - remoteExtensionKey !== null && remoteState.key === remoteExtensionKey; - const loading = - remoteExtensionKey !== null && !hasMatchingRemoteDetail && !hasMatchingRemoteError; - const error = hasMatchingRemoteError ? remoteState.error : null; - - useEffect(() => { - if (!remoteExtensionKey || hasMatchingRemoteDetail) { - return; - } - - getExtensionDetail(remoteExtensionKey) - .then((data) => { - setRemoteState({ - key: remoteExtensionKey, - detail: data, - error: null, - }); - }) - .catch((e) => { - setRemoteState({ - key: remoteExtensionKey, - detail: null, - error: e instanceof Error ? e.message : '获取扩展详情失败', - }); - }); - }, [hasMatchingRemoteDetail, remoteExtensionKey]); - - const extensionDetail = - localExtensionDetail || (hasMatchingRemoteDetail ? remoteState.detail : null); - - const formatDate = (dateString?: string) => { - if (!dateString) return '-'; - try { - const date = new Date(dateString); - return date.toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }); - } catch { - return dateString; - } - }; - - const formatFileSize = (bytes?: number) => { - if (!bytes) return '-'; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; - }; - - const formatCategory = (category?: string) => { - if (!category) return t('store.categories.all'); - return knownCategories.has(category) ? t(`store.categories.${category}`) : category; - }; + if (!extension) return null; return ( - {loading ? ( -
- +
+ +
+
{extension.name}
+
v{extension.version}
+

+ {extension.description || t('local.noDescription')} +

- ) : error ? ( -
{error}
- ) : extensionDetail ? ( - -
- {/* 基本信息 */} -
-
- -
-

- {extensionDetail.name} -

- {extensionDetail.author && ( -

{extensionDetail.author}

- )} -
- - v{extensionDetail.version} - - {extensionDetail.rating && ( -
- - - {extensionDetail.rating.toFixed(1)} - -
- )} -
-
-
- - {extensionDetail.description && ( -

- {extensionDetail.description} -

- )} -
- - {/* 详细信息 */} -
-
-
- - - {t('dialog.detail.category')} - -
-

- {formatCategory(extensionDetail.category)} -

-
- -
-
- - - {t('dialog.detail.browser')} - -
-

{t(`browser.${extensionDetail.browser}`)}

-
- -
-
- - - {t('dialog.detail.downloads')} - -
-

- {extensionDetail.downloads?.toLocaleString() || '-'} -

-
- -
-
- - - {t('dialog.detail.fileSize')} - -
-

- {formatFileSize(extensionDetail.fileSize)} -

-
-
- - {/* 权限 */} - {extensionDetail.permissions && extensionDetail.permissions.length > 0 && ( -
-
- - - {t('dialog.detail.permissions')} - -
- -
- {extensionDetail.permissions.map((permission, index) => ( - - {permission} - - ))} -
-
-
- )} - - {/* 时间信息 */} -
- {extensionDetail.updatedAt && ( -
-
- - - {t('dialog.detail.updatedAt')} - -
-

{formatDate(extensionDetail.updatedAt)}

-
- )} - - {extensionDetail.createdAt && ( -
-
- - - {t('dialog.detail.createdAt')} - -
-

{formatDate(extensionDetail.createdAt)}

-
- )} -
-
-
- ) : null} - +
- - - - ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-icon.tsx b/plugins/pages/browser-extensions/src/components/extension-icon.tsx index 6cf8fb81..6b380d6c 100644 --- a/plugins/pages/browser-extensions/src/components/extension-icon.tsx +++ b/plugins/pages/browser-extensions/src/components/extension-icon.tsx @@ -1,11 +1,11 @@ import { useMemo, useState } from 'react'; -import { Package, Puzzle } from 'lucide-react'; +import { Puzzle } from 'lucide-react'; import { cn } from '@/lib/utils'; import { resolveExtensionIconSrc } from '../utils/icon'; interface ExtensionIconProps { icon?: string | null; - source?: 'remote' | 'local'; + source?: 'local'; containerClassName?: string; imageClassName?: string; textClassName?: string; @@ -24,7 +24,7 @@ function isLikelyIconText(icon: string): boolean { export function ExtensionIcon({ icon, - source = 'remote', + source = 'local', containerClassName, imageClassName, textClassName, @@ -41,8 +41,6 @@ export function ExtensionIcon({ return resolvedIconSrc; }, [failedIconSrc, resolvedIconSrc]); - const FallbackIcon = source === 'local' ? Puzzle : Package; - return (
{icon} ) : ( - + )}
); diff --git a/plugins/pages/browser-extensions/src/components/extension-install-dialog.tsx b/plugins/pages/browser-extensions/src/components/extension-install-dialog.tsx deleted file mode 100644 index 49cccd73..00000000 --- a/plugins/pages/browser-extensions/src/components/extension-install-dialog.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import { useState, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Loader2, Check, Download, Users, Folder, Search } from 'lucide-react'; -import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; -import { Button } from '@/components/ui/button'; -import { TextareaInput } from '@/components/textarea-input'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Switch } from '@/components/ui/switch'; -import { Label } from '@/components/ui/label'; -import type { StoreExtension } from '../types'; -import { ExtensionIcon } from './extension-icon'; - -// 分组项类型 -interface GroupItem { - uuid: string; - name: string; - description?: string; -} - -interface ExtensionInstallDialogProps { - open: boolean; - extension: StoreExtension | null; - groups: GroupItem[]; - loadingGroups?: boolean; - installGroups: string[]; - installForTeam: boolean; - installing: boolean; - onOpenChange: (open: boolean) => void; - onGroupToggle: (group: string) => void; - onTeamChange: (forTeam: boolean) => void; - onConfirm: () => void; -} - -// 分组卡片组件 -const GroupCard: React.FC<{ - group: GroupItem; - isSelected: boolean; - onClick: () => void; -}> = ({ group, isSelected, onClick }) => { - return ( -
-
- -
-
-
- {group.name} -
- {group.description && ( -
{group.description}
- )} -
-
- {isSelected && } -
-
- ); -}; - -// 加载状态组件 -const LoadingState: React.FC = () => ( -
- -
-); - -// 空状态组件 -const EmptyState: React.FC<{ title: string; description: string }> = ({ title, description }) => ( -
-
- -
-

{title}

-

{description}

-
-); - -/** - * 扩展安装对话框组件 - */ -export function ExtensionInstallDialog({ - open, - extension, - groups, - loadingGroups = false, - installGroups, - installForTeam, - installing, - onOpenChange, - onGroupToggle, - onTeamChange, - onConfirm, -}: ExtensionInstallDialogProps) { - const { t } = useTranslation('extensions'); - const [searchQuery, setSearchQuery] = useState(''); - - // 过滤分组 - const filteredGroups = useMemo(() => { - if (!searchQuery.trim()) return groups; - const q = searchQuery.toLowerCase(); - return groups.filter( - (g) => g.name.toLowerCase().includes(q) || g.description?.toLowerCase().includes(q) - ); - }, [groups, searchQuery]); - - const handleOpenChange = (nextOpen: boolean) => { - if (!nextOpen) { - setSearchQuery(''); - } - onOpenChange(nextOpen); - }; - - return ( - - {extension && ( - <> - {/* 插件信息卡片 */} -
-
- -
-
-

{extension.name}

-
- - v{extension.version} - - {extension.author && ( - <> - · - - {extension.author} - - - )} -
-
-
- - {/* 分组选择区域 */} - {loadingGroups ? ( - - ) : groups.length > 0 ? ( - <> - {/* 搜索框 - 只在分组数量较多时显示 */} - {groups.length > 4 && ( -
- - setSearchQuery(e.target.value)} - className="pl-9 pr-2 text-sm min-h-9" - /> -
- )} - - {filteredGroups.length === 0 ? ( -
-

{t('dialog.install.noMatch')}

-
- ) : ( - -
- {filteredGroups.map((group) => ( - onGroupToggle(group.uuid)} - /> - ))} -
-
- )} - - ) : ( - - )} - - {/* 团队安装开关 */} -
-
-
- -
-
- -

- {installGroups.length > 0 - ? t('dialog.install.teamInstallDescriptionForGroup') - : t('dialog.install.teamInstallDescription')} -

-
-
- -
- - )} - - - - - -
- ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-pagination.tsx b/plugins/pages/browser-extensions/src/components/extension-pagination.tsx deleted file mode 100644 index 911b54a4..00000000 --- a/plugins/pages/browser-extensions/src/components/extension-pagination.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationPrevious, - PaginationNext, - PaginationEllipsis, -} from '@/components/ui/pagination'; - -interface ExtensionPaginationProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; -} - -export function ExtensionPagination({ - currentPage, - totalPages, - onPageChange, -}: ExtensionPaginationProps) { - const { t } = useTranslation('extensions'); - - const getPageNumbers = () => { - const pages: (number | 'ellipsis')[] = []; - const maxVisible = 7; - - if (totalPages <= maxVisible) { - for (let i = 1; i <= totalPages; i++) { - pages.push(i); - } - } else { - if (currentPage <= 3) { - for (let i = 1; i <= 4; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(totalPages); - } else if (currentPage >= totalPages - 2) { - pages.push(1); - pages.push('ellipsis'); - for (let i = totalPages - 3; i <= totalPages; i++) pages.push(i); - } else { - pages.push(1); - pages.push('ellipsis'); - for (let i = currentPage - 1; i <= currentPage + 1; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(totalPages); - } - } - - return pages; - }; - - return ( -
-
- {t('pagination.pageInfo', { currentPage, totalPages })} -
- - - - { - e.preventDefault(); - if (currentPage > 1) onPageChange(currentPage - 1); - }} - className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'} - > - {t('pagination.previous')} - - - - {getPageNumbers().map((page, index) => ( - - {page === 'ellipsis' ? ( - - ) : ( - { - e.preventDefault(); - onPageChange(page); - }} - isActive={page === currentPage} - className="cursor-pointer" - > - {page} - - )} - - ))} - - - { - e.preventDefault(); - if (currentPage < totalPages) onPageChange(currentPage + 1); - }} - className={ - currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer' - } - > - {t('pagination.next')} - - - - -
- ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-stats.tsx b/plugins/pages/browser-extensions/src/components/extension-stats.tsx deleted file mode 100644 index 22434905..00000000 --- a/plugins/pages/browser-extensions/src/components/extension-stats.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Puzzle, CheckCircle2, AlertCircle, Download, RefreshCw } from 'lucide-react'; - -interface ExtensionStatsProps { - total: number; - installed: number; - updates: number; - available: number; - scopeFilter?: string; - onScopeFilterChange?: (value: string) => void; - onRefresh: () => void; -} - -export function ExtensionStats({ - total, - installed, - updates, - available, - scopeFilter = 'all', - onScopeFilterChange, - onRefresh, -}: ExtensionStatsProps) { - const { t } = useTranslation('extensions'); - - return ( -
-
- - {t('stats.total')} - {total} -
-
- - {t('stats.installed')} - {installed} -
-
- - {t('stats.updates')} - {updates} -
-
- - {t('stats.available')} - {available} -
-
- - - - {t('tabs.all')} - - - {t('tabs.team')} - - - {t('tabs.personal')} - - - - -
-
- ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-store-skeleton.tsx b/plugins/pages/browser-extensions/src/components/extension-store-skeleton.tsx deleted file mode 100644 index 500b4f23..00000000 --- a/plugins/pages/browser-extensions/src/components/extension-store-skeleton.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; -import { ScrollArea } from '@/components/ui/scroll-area'; - -export function ExtensionStoreSkeleton() { - return ( -
- {/* 分类筛选 */} -
-
- {Array.from({ length: 7 }).map((_, index) => ( - - ))} -
-
- -
-
- - {/* 插件卡片列表 */} - -
-
- {Array.from({ length: 12 }).map((_, index) => ( -
- {/* 图标和名称 */} -
- -
- - -
-
- - {/* 描述 */} - - - - {/* 浏览器和评分 */} -
- -
- - -
-
- - {/* 下载量和作者 */} -
- - -
- - {/* 按钮 */} - -
- ))} -
-
-
- - {/* 分页 */} -
- -
- - - - - -
-
-
- ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-store.tsx b/plugins/pages/browser-extensions/src/components/extension-store.tsx deleted file mode 100644 index 8982ec85..00000000 --- a/plugins/pages/browser-extensions/src/components/extension-store.tsx +++ /dev/null @@ -1,362 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { Download, CheckCircle2, Star, Users, ArrowUpDown, SearchX } from 'lucide-react'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Button } from '@/components/ui/button'; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationPrevious, - PaginationNext, - PaginationEllipsis, -} from '@/components/ui/pagination'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; - -import type { StoreExtension, SortOption } from '../types'; -import { STORE_PAGE_SIZE_OPTIONS } from '../constants'; -import { ExtensionIcon } from './extension-icon'; - -export type { StoreExtension }; - -interface ExtensionStoreProps { - extensions: StoreExtension[]; - searchQuery: string; - totalCount: number; - currentPage: number; - totalPages: number; - pageSize: number; - categoryFilter: string; - sortBy: SortOption; - onCategoryChange: (category: string) => void; - onSortChange: (sort: SortOption) => void; - onPageChange: (page: number) => void; - onPageSizeChange: (size: number) => void; - onInstall: (id: string) => void; -} - -// 分类颜色配置 -const categoryStyles: Record = { - automation: 'border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400', - security: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', - productivity: 'border-violet-500/40 bg-violet-500/10 text-violet-600 dark:text-violet-400', - tools: 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400', - media: 'border-rose-500/40 bg-rose-500/10 text-rose-600 dark:text-rose-400', - social: 'border-sky-500/40 bg-sky-500/10 text-sky-600 dark:text-sky-400', -}; - -// 格式化下载量 -function formatDownloads(downloads: number | undefined): string { - if (!downloads) return '0'; - if (downloads >= 1000000) return `${(downloads / 1000000).toFixed(1)}M`; - if (downloads >= 1000) return `${(downloads / 1000).toFixed(0)}K`; - return downloads.toString(); -} - -function ExtensionCard({ - extension, - onInstall, -}: { - extension: StoreExtension; - onInstall: (id: string) => void; -}) { - const { t } = useTranslation('extensions'); - const isInstalled = extension.status === 'installed' || extension.status === 'update'; - - const categoryBadge = extension.category ? ( - - {t(`store.categories.${extension.category}`)} - - ) : null; - - return ( -
- {/* 头部:图标 + 名称 + 分类 */} -
-
- -
-
-
-

{extension.name}

-
-
- - v{extension.version} - - {categoryBadge} -
-
-
- - {/* 描述 */} -

- {extension.description} -

- - {/* 统计信息 */} -
- - - {formatDownloads(extension.downloads)} - - {extension.rating && ( - - - {extension.rating.toFixed(1)} - - )} - {extension.author && ( - {extension.author} - )} -
- - {/* 操作按钮 */} -
- {isInstalled ? ( - - - {t('store.installed')} - - ) : ( - - )} -
-
- ); -} - -export function ExtensionStore({ - extensions, - searchQuery, - totalCount, - currentPage, - totalPages, - pageSize, - categoryFilter, - sortBy, - onCategoryChange, - onSortChange, - onPageChange, - onPageSizeChange, - onInstall, -}: ExtensionStoreProps) { - const { t } = useTranslation('extensions'); - - const categories = [ - { value: 'all', label: t('store.categories.all') }, - { value: 'automation', label: t('store.categories.automation') }, - { value: 'security', label: t('store.categories.security') }, - { value: 'productivity', label: t('store.categories.productivity') }, - { value: 'tools', label: t('store.categories.tools') }, - { value: 'media', label: t('store.categories.media') }, - { value: 'social', label: t('store.categories.social') }, - ]; - - const sortOptions: { value: SortOption; label: string }[] = [ - { value: 'downloads', label: t('store.sort.downloads') }, - { value: 'rating', label: t('store.sort.rating') }, - { value: 'name', label: t('store.sort.name') }, - { value: 'newest', label: t('store.sort.newest') }, - ]; - - const getPageNumbers = () => { - const pages: (number | 'ellipsis')[] = []; - const maxVisible = 7; - - if (totalPages <= maxVisible) { - for (let i = 1; i <= totalPages; i++) { - pages.push(i); - } - } else { - if (currentPage <= 3) { - for (let i = 1; i <= 4; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(totalPages); - } else if (currentPage >= totalPages - 2) { - pages.push(1); - pages.push('ellipsis'); - for (let i = totalPages - 3; i <= totalPages; i++) pages.push(i); - } else { - pages.push(1); - pages.push('ellipsis'); - for (let i = currentPage - 1; i <= currentPage + 1; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(totalPages); - } - } - - return pages; - }; - - return ( -
- {/* 筛选栏 */} -
- {/* 分类筛选 */} -
- {categories.map((cat) => ( - - ))} -
- - {/* 右侧:排序 + 统计 */} -
- - - {totalCount > 0 && ( - - {t('store.totalExtensions', { count: totalCount })} - - )} -
-
- - {/* 插件卡片列表 - 可滚动区域 */} - -
- {extensions.length === 0 ? ( -
-
- -
-

{t('store.noResults')}

-

- {searchQuery ? t('store.noResultsHint') : t('store.noResultsHint')} -

-
- ) : ( -
- {extensions.map((ext) => ( - - ))} -
- )} -
-
- - {/* 分页 - 固定在底部 */} -
-
-
- {t('store.pageInfo', { currentPage, totalPages })} -
-
- - {t('store.pageSize')} - - - - {t('store.totalItems', { total: totalCount })} - -
-
- - - - { - e.preventDefault(); - if (currentPage > 1) onPageChange(currentPage - 1); - }} - className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'} - > - {t('pagination.previous')} - - - - {getPageNumbers().map((page, index) => ( - - {page === 'ellipsis' ? ( - - ) : ( - { - e.preventDefault(); - onPageChange(page); - }} - isActive={page === currentPage} - className="cursor-pointer" - > - {page} - - )} - - ))} - - - { - e.preventDefault(); - if (currentPage < totalPages) onPageChange(currentPage + 1); - }} - className={ - currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer' - } - > - {t('pagination.next')} - - - - -
-
- ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-table-row.tsx b/plugins/pages/browser-extensions/src/components/extension-table-row.tsx deleted file mode 100644 index 78cf1a2b..00000000 --- a/plugins/pages/browser-extensions/src/components/extension-table-row.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { - CheckCircle2, - AlertCircle, - MoreVertical, - Trash2, - RefreshCw, - ExternalLink, - Info, - ShieldCheck, - Ban, - Play, -} from 'lucide-react'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { - DataTableRowContainer, - DataTableCell, - DataTableCheckboxCell, - DataTableActionsCell, -} from '@/components/data-table'; -import type { ExtensionItem } from '../types'; -import { ExtensionIcon } from './extension-icon'; - -interface ExtensionTableRowProps { - extension: ExtensionItem; - isSelected?: boolean; - onSelect?: (id: string, selected: boolean) => void; - onUpdate?: (id: string) => void; - onUninstall?: (id: string, name: string) => void; - onViewDetails?: (id: string) => void; - onHomepage?: (id: string) => void; - onSecurityCheck?: (id: string) => void; - onDisable?: (id: string, name: string) => void; - onEnable?: (id: string, name: string) => void; -} - -export function ExtensionTableRow({ - extension, - isSelected = false, - onSelect, - onUpdate, - onUninstall, - onViewDetails, - onHomepage, - onSecurityCheck, - onDisable, - onEnable, -}: ExtensionTableRowProps) { - const { t } = useTranslation('extensions'); - - const formatDate = (dateString?: string) => { - if (!dateString) return '-'; - try { - const date = new Date(dateString); - return date.toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }); - } catch { - return dateString; - } - }; - - const getStatusBadge = () => { - switch (extension.status) { - case 'active': - case 'installed': - return ( - - - {t('status.installed')} - - ); - case 'update': - return ( - - - {t('status.update')} - - ); - case 'disabled': - return ( - - - {t('status.disabled')} - - ); - default: - return null; - } - }; - - const getBrowserBadge = () => { - const colors: Record = { - chrome: 'border-yellow-500/30 bg-yellow-500/10 text-yellow-600', - firefox: 'border-orange-500/30 bg-orange-500/10 text-orange-500', - edge: 'border-blue-500/30 bg-blue-500/10 text-blue-500', - all: 'border-purple-500/30 bg-purple-500/10 text-purple-500', - }; - const labels: Record = { - chrome: 'Chrome', - firefox: 'Firefox', - edge: 'Edge', - all: t('browser.all'), - }; - return ( - - {labels[extension.browser]} - - ); - }; - - return ( - - {/* 选择框列 */} - onSelect?.(extension.id, selected)} - /> - - {/* 名称列 */} - -
- - {extension.name} - {extension.source === 'local' && ( - - {t('local.badge')} - - )} -
-
- {extension.description} -
-
- - {/* 状态列 */} - {getStatusBadge()} - - {/* 浏览器列 */} - {getBrowserBadge()} - - {/* 分组列 */} - - {extension.groups && extension.groups.length > 0 ? ( -
- {extension.groups.map((group) => ( - - {group.name} - - ))} -
- ) : ( - - - )} -
- - {/* 下载量列 */} - - - {extension.downloads?.toLocaleString() || '-'} - - - - {/* 更新时间列 */} - - {formatDate(extension.updatedAt)} - - - {/* 操作列 */} - - {/* 安全检查 */} - - {/* 检查更新 */} - - {/* 更多操作 */} - - - - - - onViewDetails?.(extension.id)} - className="cursor-pointer" - > - - {t('table.actions.details')} - - onHomepage?.(extension.id)} - className="cursor-pointer" - disabled={false} - > - - {t('table.actions.homepage')} - - - {/* 只有团队插件才显示禁用/启用选项 */} - {extension.scope === 'team' && ( - <> - {extension.status === 'disabled' ? ( - onEnable?.(extension.id, extension.name)} - className="cursor-pointer" - > - - {t('table.actions.enable')} - - ) : ( - onDisable?.(extension.id, extension.name)} - className="cursor-pointer" - > - - {t('table.actions.disable')} - - )} - - - )} - onUninstall?.(extension.id, extension.name)} - className="cursor-pointer text-destructive focus:text-destructive" - > - - {t('table.actions.uninstall')} - - - - -
- ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-table-skeleton.tsx b/plugins/pages/browser-extensions/src/components/extension-table-skeleton.tsx deleted file mode 100644 index 078e8949..00000000 --- a/plugins/pages/browser-extensions/src/components/extension-table-skeleton.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -interface ExtensionTableSkeletonProps { - rows?: number; -} - -export function ExtensionTableSkeleton({ rows = 8 }: ExtensionTableSkeletonProps) { - return ( - <> - {Array.from({ length: rows }).map((_, index) => ( - - {/* 复选框列 */} - -
- -
- - {/* 名称列 */} - -
- - -
- - - {/* 版本列 */} - - - - {/* 状态列 */} - - - - {/* 浏览器列 */} - - - - {/* 作者列 */} - - - - {/* 下载量列 */} - - - - {/* 更新时间列 */} - - - - {/* 操作列 */} - -
- - - -
- - - ))} - - ); -} diff --git a/plugins/pages/browser-extensions/src/components/extension-table.tsx b/plugins/pages/browser-extensions/src/components/extension-table.tsx deleted file mode 100644 index c5befeb8..00000000 --- a/plugins/pages/browser-extensions/src/components/extension-table.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { Puzzle } from 'lucide-react'; -import { DataTable, type ColumnDef } from '@/components/data-table'; -import { ExtensionTableRow } from './extension-table-row'; -import type { ExtensionItem } from '../types'; - -export type { ExtensionItem }; - -/** - * 空状态组件 - */ -const EmptyState: React.FC<{ - t: (key: string) => string; -}> = ({ t }) => ( -
-
- -
-

{t('table.empty')}

-

- {t('table.emptyDescription')} -

-
-); - -interface ExtensionTableProps { - extensions: ExtensionItem[]; - selectedIds?: Set; - onSelect?: (id: string, selected: boolean) => void; - onSelectAll?: (selected: boolean) => void; - onUpdate?: (id: string) => void; - onUninstall?: (id: string, name: string) => void; - onViewDetails?: (id: string) => void; - onHomepage?: (id: string) => void; - onSecurityCheck?: (id: string) => void; - onDisable?: (id: string, name: string) => void; - onEnable?: (id: string, name: string) => void; - loading?: boolean; -} - -export function ExtensionTable({ - extensions, - selectedIds = new Set(), - onSelect, - onSelectAll, - onUpdate, - onUninstall, - onViewDetails, - onHomepage, - onSecurityCheck, - onDisable, - onEnable, - loading = false, -}: ExtensionTableProps) { - const { t } = useTranslation('extensions'); - - // 定义列 - const columns: ColumnDef[] = [ - { - id: 'name', - header: t('table.headers.name'), - cell: () => null, - width: 256, - }, - { - id: 'status', - header: t('table.headers.status'), - cell: () => null, - width: 112, - }, - { - id: 'browser', - header: t('table.headers.browser'), - cell: () => null, - width: 96, - }, - { - id: 'groups', - header: t('table.headers.groups'), - cell: () => null, - width: 128, - }, - { - id: 'downloads', - header: t('table.headers.downloads'), - cell: () => null, - width: 96, - }, - { - id: 'updatedAt', - header: t('table.headers.updatedAt'), - cell: () => null, - width: 112, - }, - { - id: 'actions', - header: t('table.headers.actions'), - cell: () => null, - width: 128, - }, - ]; - - // 处理选择变化 - const handleSelectionChange = (newSelectedIds: Set) => { - const allSelected = extensions.length > 0 && extensions.every((e) => newSelectedIds.has(e.id)); - const noneSelected = extensions.every((e) => !newSelectedIds.has(e.id)); - - if (allSelected && onSelectAll) { - onSelectAll(true); - } else if (noneSelected && onSelectAll) { - onSelectAll(false); - } else if (onSelect) { - extensions.forEach((extension) => { - const wasSelected = selectedIds.has(extension.id); - const isNowSelected = newSelectedIds.has(extension.id); - if (wasSelected !== isNowSelected) { - onSelect(extension.id, isNowSelected); - } - }); - } - }; - - return ( - extension.id} - loading={loading} - skeletonRows={8} - emptyText={} - selectable - selectedIds={selectedIds} - onSelectionChange={handleSelectionChange} - renderRow={({ row, rowKey, isSelected, onSelect: handleSelect }) => ( - handleSelect(selected)} - onUpdate={onUpdate} - onUninstall={onUninstall} - onViewDetails={onViewDetails} - onHomepage={onHomepage} - onSecurityCheck={onSecurityCheck} - onDisable={onDisable} - onEnable={onEnable} - /> - )} - /> - ); -} diff --git a/plugins/pages/browser-extensions/src/constants.ts b/plugins/pages/browser-extensions/src/constants.ts deleted file mode 100644 index 47ccff79..00000000 --- a/plugins/pages/browser-extensions/src/constants.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * 每页显示的项目数(已安装视图) - */ -export const ITEMS_PER_PAGE = 10; - -/** - * 每页显示的项目数(商店视图)- 默认值 - */ -export const STORE_ITEMS_PER_PAGE = 12; - -/** - * 商店分页大小选项 - */ -export const STORE_PAGE_SIZE_OPTIONS = [12, 24, 48, 96]; diff --git a/plugins/pages/browser-extensions/src/hooks/use-batch-uninstall-dialog.ts b/plugins/pages/browser-extensions/src/hooks/use-batch-uninstall-dialog.ts deleted file mode 100644 index 46f396db..00000000 --- a/plugins/pages/browser-extensions/src/hooks/use-batch-uninstall-dialog.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useState } from 'react'; - -interface UseBatchUninstallDialogReturn { - batchUninstallDialogOpen: boolean; - openBatchUninstallDialog: () => void; - closeBatchUninstallDialog: () => void; -} - -/** - * 批量卸载对话框状态管理 Hook - */ -export function useBatchUninstallDialog(): UseBatchUninstallDialogReturn { - const [batchUninstallDialogOpen, setBatchUninstallDialogOpen] = useState(false); - - const openBatchUninstallDialog = () => { - setBatchUninstallDialogOpen(true); - }; - - const closeBatchUninstallDialog = () => { - setBatchUninstallDialogOpen(false); - }; - - return { - batchUninstallDialogOpen, - openBatchUninstallDialog, - closeBatchUninstallDialog, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-detail-dialog.ts b/plugins/pages/browser-extensions/src/hooks/use-detail-dialog.ts deleted file mode 100644 index d7afabc0..00000000 --- a/plugins/pages/browser-extensions/src/hooks/use-detail-dialog.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useState } from 'react'; - -import type { ExtensionItem } from '../types'; - -interface UseDetailDialogReturn { - detailDialogOpen: boolean; - viewingExtension: ExtensionItem | null; - openDetailDialog: (extension: ExtensionItem) => void; - closeDetailDialog: () => void; -} - -/** - * 详情对话框状态管理 Hook - */ -export function useDetailDialog(): UseDetailDialogReturn { - const [detailDialogOpen, setDetailDialogOpen] = useState(false); - const [viewingExtension, setViewingExtension] = useState(null); - - const openDetailDialog = (extension: ExtensionItem) => { - setViewingExtension(extension); - setDetailDialogOpen(true); - }; - - const closeDetailDialog = () => { - setDetailDialogOpen(false); - setViewingExtension(null); - }; - - return { - detailDialogOpen, - viewingExtension, - openDetailDialog, - closeDetailDialog, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-extension-filters.ts b/plugins/pages/browser-extensions/src/hooks/use-extension-filters.ts deleted file mode 100644 index 205db03d..00000000 --- a/plugins/pages/browser-extensions/src/hooks/use-extension-filters.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useMemo } from 'react'; -import type { ExtensionItem, StoreExtension } from '../types'; - -interface ExtensionFilters { - searchQuery: string; - scopeFilter: string; -} - -/** - * 扩展过滤 Hook - */ -export function useExtensionFilters( - extensions: ExtensionItem[], - filters: ExtensionFilters -): ExtensionItem[] { - return useMemo(() => { - let result = extensions.filter((e) => e.status === 'installed' || e.status === 'update'); - - // 搜索过滤 - if (filters.searchQuery) { - const query = filters.searchQuery.toLowerCase(); - result = result.filter( - (ext) => - ext.name.toLowerCase().includes(query) || - ext.description.toLowerCase().includes(query) || - ext.author?.toLowerCase().includes(query) - ); - } - - // 作用域过滤(如果需要) - // if (filters.scopeFilter && filters.scopeFilter !== 'all') { - // result = result.filter((ext) => ext.scope === filters.scopeFilter); - // } - - return result; - }, [extensions, filters.searchQuery, filters.scopeFilter]); -} - -/** - * 转换为商店扩展格式 - */ -export function useStoreExtensions(extensions: ExtensionItem[]): StoreExtension[] { - return useMemo(() => { - return extensions.map((ext) => ({ - ...ext, - // 使用后端返回的 rating,如果没有则不显示 - rating: ext.rating, - })); - }, [extensions]); -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-extension-handlers.ts b/plugins/pages/browser-extensions/src/hooks/use-extension-handlers.ts deleted file mode 100644 index a1835a01..00000000 --- a/plugins/pages/browser-extensions/src/hooks/use-extension-handlers.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useCallback } from 'react'; -import { toast } from 'sonner'; -import type { ExtensionItem } from '../types'; -import { useExtensionOperations } from './use-extension-operations'; -import { useInstallDialog } from './use-install-dialog'; -import { useUninstallDialog } from './use-uninstall-dialog'; -import { useBatchUninstallDialog } from './use-batch-uninstall-dialog'; -import { useExtensionSelection } from './use-extension-selection'; -import { useDetailDialog } from './use-detail-dialog'; -import { useHomepageDialog } from './use-homepage-dialog'; -import { useToggleDialog } from './use-toggle-dialog'; - -interface UseExtensionHandlersProps { - extensions: ExtensionItem[]; - paginatedExtensions: ExtensionItem[]; - onRefresh: () => Promise; -} - -interface UseExtensionHandlersReturn { - // 安装相关 - handleInstall: (id: string) => void; - handleConfirmInstall: () => Promise; - installDialog: ReturnType; - installing: boolean; - - // 更新相关 - handleUpdate: (id: string) => Promise; - - // 卸载相关 - handleUninstall: (id: string, name: string) => void; - handleRemove: (id: string, name: string) => void; - handleConfirmUninstall: () => Promise; - uninstallDialog: ReturnType; - - // 批量操作相关 - handleBatchUpdate: () => Promise; - handleBatchUninstall: () => void; - handleConfirmBatchUninstall: () => Promise; - batchUninstallDialog: ReturnType; - - // 禁用/启用相关 - handleDisable: (id: string, name: string) => void; - handleEnable: (id: string, name: string) => void; - handleConfirmToggle: () => Promise; - toggleDialog: ReturnType; - - // 其他操作 - handleViewDetails: (id: string) => void; - handleHomepage: (id: string) => void; - handleSecurityCheck: (id: string) => void; - detailDialog: ReturnType; - homepageDialog: ReturnType; - - // 选择相关 - selection: ReturnType; -} - -/** - * 扩展操作事件处理 Hook - * 整合所有扩展相关的操作和事件处理逻辑 - */ -export function useExtensionHandlers({ - extensions, - paginatedExtensions, - onRefresh, -}: UseExtensionHandlersProps): UseExtensionHandlersReturn { - const { t } = useTranslation('extensions'); - - // 对话框状态管理 - const installDialog = useInstallDialog(); - const uninstallDialog = useUninstallDialog(); - const batchUninstallDialog = useBatchUninstallDialog(); - const detailDialog = useDetailDialog(); - const homepageDialog = useHomepageDialog(); - const toggleDialog = useToggleDialog(); - - // 选择管理 - const selection = useExtensionSelection(paginatedExtensions); - - // 操作逻辑 - const operations = useExtensionOperations(() => { - void onRefresh(); - }); - - // 安装处理 - const handleInstall = useCallback( - (id: string) => { - const ext = extensions.find((e) => e.id === id); - if (ext) { - if (ext.source === 'local') { - void operations - .installExtension( - { - ...ext, - isInstalled: ext.status === 'installed' || ext.status === 'update', - }, - [], - false - ) - .catch((e) => { - toast.error(e instanceof Error ? e.message : '安装失败'); - }); - return; - } - - installDialog.openInstallDialog({ - ...ext, - isInstalled: ext.status === 'installed' || ext.status === 'update', - }); - } - }, - [extensions, installDialog] - ); - - const handleConfirmInstall = useCallback(async () => { - if (!installDialog.installingExt) return; - try { - await operations.installExtension( - installDialog.installingExt, - installDialog.installGroups, - installDialog.installForTeam - ); - installDialog.closeInstallDialog(); - } catch (e) { - toast.error(e instanceof Error ? e.message : '安装失败'); - } - }, [installDialog, operations]); - - // 更新处理 - const handleUpdate = useCallback( - async (_id: string) => { - // 直接提示检查通过 - toast.success(t('update.checkPassed')); - }, - [t] - ); - - // 卸载处理 - const handleUninstall = useCallback( - (id: string, _name: string) => { - const ext = extensions.find((e) => e.id === id); - if (ext) { - uninstallDialog.openUninstallDialog(ext); - } - }, - [extensions, uninstallDialog] - ); - - const handleConfirmUninstall = useCallback(async () => { - if (!uninstallDialog.uninstallingExt) return; - if (uninstallDialog.action === 'remove') { - await operations.removeExtension(uninstallDialog.uninstallingExt); - } else { - await operations.uninstallExtension(uninstallDialog.uninstallingExt); - } - uninstallDialog.closeUninstallDialog(); - }, [uninstallDialog, operations]); - - const handleRemove = useCallback( - (id: string, _name: string) => { - const ext = extensions.find((e) => e.id === id); - if (ext) { - uninstallDialog.openRemoveDialog(ext); - } - }, - [extensions, uninstallDialog] - ); - - // 批量操作处理 - const handleBatchUpdate = useCallback(async () => { - const selectedExtensions = paginatedExtensions.filter((ext) => - selection.selectedIds.has(ext.id) - ); - await operations.batchUpdate(selectedExtensions); - selection.clearSelection(); - }, [paginatedExtensions, selection, operations]); - - const handleBatchUninstall = useCallback(() => { - batchUninstallDialog.openBatchUninstallDialog(); - }, [batchUninstallDialog]); - - const handleConfirmBatchUninstall = useCallback(async () => { - const selectedExtensions = paginatedExtensions.filter((ext) => - selection.selectedIds.has(ext.id) - ); - await operations.batchUninstall(selectedExtensions); - await onRefresh(); - selection.clearSelection(); - batchUninstallDialog.closeBatchUninstallDialog(); - }, [paginatedExtensions, selection, operations, onRefresh, batchUninstallDialog]); - - // 其他操作 - const handleViewDetails = useCallback( - (id: string) => { - const ext = extensions.find((item) => item.id === id); - if (ext) { - detailDialog.openDetailDialog(ext); - } - }, - [detailDialog, extensions] - ); - - const handleHomepage = useCallback( - (id: string) => { - const ext = extensions.find((e) => e.id === id); - if (ext) { - homepageDialog.openHomepageDialog(ext); - } else { - toast.error(t('dialog.homepage.noHomepage')); - } - }, - [extensions, homepageDialog, t] - ); - - const handleSecurityCheck = useCallback( - (_id: string) => { - // 直接提示检查通过 - toast.success(t('securityCheck.passed')); - }, - [t] - ); - - // 禁用/启用处理 - const handleDisable = useCallback( - (id: string, _name: string) => { - const ext = extensions.find((e) => e.id === id); - if (ext) { - toggleDialog.openToggleDialog(ext, 'disable'); - } - }, - [extensions, toggleDialog] - ); - - const handleEnable = useCallback( - (id: string, _name: string) => { - const ext = extensions.find((e) => e.id === id); - if (ext) { - toggleDialog.openToggleDialog(ext, 'enable'); - } - }, - [extensions, toggleDialog] - ); - - const handleConfirmToggle = useCallback(async () => { - if (!toggleDialog.togglingExt) return; - if (toggleDialog.toggleAction === 'disable') { - await operations.disableExtension(toggleDialog.togglingExt); - } else { - await operations.enableExtension(toggleDialog.togglingExt); - } - toggleDialog.closeToggleDialog(); - }, [toggleDialog, operations]); - - return { - handleInstall, - handleConfirmInstall, - installDialog, - installing: operations.installing, - handleUpdate, - handleUninstall, - handleRemove, - handleConfirmUninstall, - uninstallDialog, - handleBatchUpdate, - handleBatchUninstall, - handleConfirmBatchUninstall, - batchUninstallDialog, - handleDisable, - handleEnable, - handleConfirmToggle, - toggleDialog, - handleViewDetails, - handleHomepage, - handleSecurityCheck, - detailDialog, - homepageDialog, - selection, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-extension-operations.ts b/plugins/pages/browser-extensions/src/hooks/use-extension-operations.ts deleted file mode 100644 index c7eab5ea..00000000 --- a/plugins/pages/browser-extensions/src/hooks/use-extension-operations.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { useState } from 'react'; -import type { ExtensionItem, StoreExtension } from '../types'; -import { - installExtension as apiInstallExtension, - uninstallExtension as apiUninstallExtension, - updateExtension as apiUpdateExtension, - batchUpdateExtensions as apiBatchUpdateExtensions, - disableExtension as apiDisableExtension, - enableExtension as apiEnableExtension, - installLocalExtension as apiInstallLocalExtension, - uninstallLocalExtension as apiUninstallLocalExtension, - removeLocalExtension as apiRemoveLocalExtension, - disableLocalExtension as apiDisableLocalExtension, - enableLocalExtension as apiEnableLocalExtension, -} from '../api'; - -interface UseExtensionOperationsReturn { - installing: boolean; - installExtension: ( - extension: StoreExtension, - groupIds: string[], - forTeam: boolean - ) => Promise; - updateExtension: (id: string) => Promise; - uninstallExtension: (extension: ExtensionItem) => Promise; - removeExtension: (extension: ExtensionItem) => Promise; - batchUpdate: (extensions: ExtensionItem[]) => Promise; - batchUninstall: (extensions: ExtensionItem[]) => Promise; - disableExtension: (extension: ExtensionItem) => Promise; - enableExtension: (extension: ExtensionItem) => Promise; -} - -/** - * 扩展操作逻辑 Hook - */ -export function useExtensionOperations(onComplete?: () => void): UseExtensionOperationsReturn { - const [installing, setInstalling] = useState(false); - - const installExtension = async ( - extension: StoreExtension, - groupIds: string[], - forTeam: boolean - ) => { - setInstalling(true); - try { - if (extension.source === 'local') { - if (!extension.recordId) { - throw new Error('本地插件缺少 recordId'); - } - await apiInstallLocalExtension(extension.recordId); - onComplete?.(); - return; - } - - // 如果选择了分组 - if (groupIds.length > 0) { - // 安装到分组,is_team_shared 由 forTeam 决定 - await apiInstallExtension({ - extension_id: extension.id, - group_ids: groupIds, - for_team: false, - is_team_shared: forTeam, // forTeam 决定是否团队共享 - }); - } else { - // 没有选择分组,根据 forTeam 决定安装到用户还是团队 - await apiInstallExtension({ - extension_id: extension.id, - group_ids: undefined, - for_team: forTeam, - is_team_shared: undefined, - }); - } - onComplete?.(); - } finally { - setInstalling(false); - } - }; - - const updateExtension = async (id: string): Promise => { - try { - const result = await apiUpdateExtension(id); - onComplete?.(); - return { - id: result.extensionId, - uuid: result.id, - recordId: result.recordId, - extensionId: result.extensionId, - name: result.name, - description: result.description, - version: result.version, - icon: result.icon || '', - browser: result.browser as ExtensionItem['browser'], - status: result.status, - source: result.source, - author: result.author, - homepage: result.homepage, - downloads: result.downloads, - rating: result.rating, - updatedAt: result.updatedAt, - createdAt: result.createdAt, - fileSize: result.fileSize, - permissions: result.permissions, - hash: result.hash, - scope: result.scope, - groups: result.groups, - category: result.category, - }; - } catch { - return null; - } - }; - - const uninstallExtension = async (extension: ExtensionItem) => { - try { - if (extension.source === 'local') { - if (!extension.recordId) { - throw new Error('本地插件缺少 recordId'); - } - await apiUninstallLocalExtension(extension.recordId); - onComplete?.(); - return; - } - - // 根据 scope 决定卸载类型和目标 - let targetType: 'user' | 'team' | 'group' = 'user'; - let targetUuid: string | undefined = undefined; - - // 从 extension 中获取 scope 信息 - const scope = (extension as any).scope as string | undefined; - - if (scope === 'team') { - targetType = 'team'; - } else if (scope === 'group-personal' || scope === 'group-team') { - targetType = 'group'; - // 如果是分组插件,需要提供 group_uuid - // 从 groups 数组中获取第一个分组的 uuid - const groups = (extension as any).groups as Array<{ uuid: string; name: string }> | undefined; - if (groups && groups.length > 0) { - targetUuid = groups[0].uuid; - } - } else { - // 默认为用户插件 - targetType = 'user'; - } - - await apiUninstallExtension(extension.id, targetType, targetUuid); - onComplete?.(); - } catch { - // 错误已处理 - } - }; - - const batchUpdate = async (extensions: ExtensionItem[]) => { - try { - const remoteIds = extensions - .filter((extension) => extension.source === 'remote') - .map((extension) => extension.id); - if (remoteIds.length > 0) { - await apiBatchUpdateExtensions(remoteIds); - onComplete?.(); - } - } catch { - // 忽略错误 - } - }; - - const removeExtension = async (extension: ExtensionItem) => { - try { - if (extension.source === 'local') { - if (!extension.recordId) { - throw new Error('本地插件缺少 recordId'); - } - await apiRemoveLocalExtension(extension.recordId); - } else { - await uninstallExtension(extension); - return; - } - onComplete?.(); - } catch { - // 错误已处理 - } - }; - - const batchUninstall = async (extensions: ExtensionItem[]) => { - // 后端没有批量卸载接口,逐个卸载 - for (const ext of extensions) { - try { - await uninstallExtension(ext); - } catch { - // 忽略单个错误 - } - } - onComplete?.(); - }; - - const disableExtension = async (extension: ExtensionItem) => { - try { - if (extension.source === 'local') { - if (!extension.recordId) { - throw new Error('本地插件缺少 recordId'); - } - await apiDisableLocalExtension(extension.recordId); - } else { - await apiDisableExtension(extension.id); - } - onComplete?.(); - } catch { - // 错误已处理 - } - }; - - const enableExtension = async (extension: ExtensionItem) => { - try { - if (extension.source === 'local') { - if (!extension.recordId) { - throw new Error('本地插件缺少 recordId'); - } - await apiEnableLocalExtension(extension.recordId); - } else { - await apiEnableExtension(extension.id); - } - onComplete?.(); - } catch { - // 错误已处理 - } - }; - - return { - installing, - installExtension, - updateExtension, - uninstallExtension, - removeExtension, - batchUpdate, - batchUninstall, - disableExtension, - enableExtension, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-extension-pagination.ts b/plugins/pages/browser-extensions/src/hooks/use-extension-pagination.ts deleted file mode 100644 index 7fb98246..00000000 --- a/plugins/pages/browser-extensions/src/hooks/use-extension-pagination.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useMemo } from 'react'; -import type { ExtensionItem } from '../types'; -import { ITEMS_PER_PAGE } from '../constants'; - -interface UseExtensionPaginationReturn { - paginatedExtensions: ExtensionItem[]; - totalPages: number; - startIndex: number; - endIndex: number; -} - -/** - * 扩展分页 Hook - */ -export function useExtensionPagination( - extensions: ExtensionItem[], - currentPage: number -): UseExtensionPaginationReturn { - const { paginatedExtensions, totalPages, startIndex, endIndex } = useMemo(() => { - const totalPages = Math.max(1, Math.ceil(extensions.length / ITEMS_PER_PAGE)); - const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; - const endIndex = startIndex + ITEMS_PER_PAGE; - const paginatedExtensions = extensions.slice(startIndex, endIndex); - - return { - paginatedExtensions, - totalPages, - startIndex, - endIndex, - }; - }, [extensions, currentPage]); - - return { - paginatedExtensions, - totalPages, - startIndex, - endIndex, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-extension-selection.ts b/plugins/pages/browser-extensions/src/hooks/use-extension-selection.ts deleted file mode 100644 index 1b0e0e59..00000000 --- a/plugins/pages/browser-extensions/src/hooks/use-extension-selection.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useState, useMemo } from 'react'; -import type { ExtensionItem } from '../types'; - -interface UseExtensionSelectionReturn { - selectedIds: Set; - allSelected: boolean; - someSelected: boolean; - select: (id: string, selected: boolean) => void; - selectAll: (extensions: ExtensionItem[], selected: boolean) => void; - clearSelection: () => void; -} - -/** - * 扩展选择逻辑 Hook - */ -export function useExtensionSelection(extensions: ExtensionItem[]): UseExtensionSelectionReturn { - const [selectedIds, setSelectedIds] = useState>(new Set()); - - const select = (id: string, selected: boolean) => { - setSelectedIds((prev) => { - const next = new Set(prev); - if (selected) { - next.add(id); - } else { - next.delete(id); - } - return next; - }); - }; - - const selectAll = (extensionsToSelect: ExtensionItem[], selected: boolean) => { - if (selected) { - setSelectedIds(new Set(extensionsToSelect.map((e) => e.id))); - } else { - setSelectedIds(new Set()); - } - }; - - const clearSelection = () => { - setSelectedIds(new Set()); - }; - - const allSelected = useMemo(() => { - return extensions.length > 0 && extensions.every((ext) => selectedIds.has(ext.id)); - }, [extensions, selectedIds]); - - const someSelected = useMemo(() => { - return extensions.some((ext) => selectedIds.has(ext.id)); - }, [extensions, selectedIds]); - - return { - selectedIds, - allSelected, - someSelected, - select, - selectAll, - clearSelection, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-extension-stats.ts b/plugins/pages/browser-extensions/src/hooks/use-extension-stats.ts deleted file mode 100644 index 184dbc9e..00000000 --- a/plugins/pages/browser-extensions/src/hooks/use-extension-stats.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useMemo } from 'react'; -import type { ExtensionItem } from '../types'; - -interface ExtensionStats { - installedCount: number; - updateCount: number; - availableCount: number; - totalCount: number; -} - -/** - * 扩展统计数据 Hook - */ -export function useExtensionStats(extensions: ExtensionItem[]): ExtensionStats { - return useMemo(() => { - const installedCount = extensions.filter( - (e) => e.status === 'installed' || e.status === 'active' || e.status === 'disabled' - ).length; - const updateCount = extensions.filter((e) => e.status === 'update').length; - const availableCount = extensions.filter((e) => e.status === 'available').length; - - return { - installedCount, - updateCount, - availableCount, - totalCount: extensions.length, - }; - }, [extensions]); -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-extensions.ts b/plugins/pages/browser-extensions/src/hooks/use-extensions.ts deleted file mode 100644 index d01bd74d..00000000 --- a/plugins/pages/browser-extensions/src/hooks/use-extensions.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { useState, useCallback } from 'react'; -import { listExtensions, listInstalledExtensions, listLocalExtensions, type Extension } from '../api'; -import type { ExtensionItem } from '../types'; - -interface UseExtensionsParams { - page?: number; - pageSize?: number; - search?: string; - category?: string; - sortBy?: 'downloads' | 'rating' | 'name' | 'newest'; - sortOrder?: 'asc' | 'desc'; -} - -interface UseExtensionsReturn { - extensions: ExtensionItem[]; - total: number; - loading: boolean; - error: string | null; - refresh: () => Promise; -} - -/** - * 将 API Extension 转换为 ExtensionItem - */ -function toExtensionItem(ext: Extension): ExtensionItem { - return { - // 业务 ID 使用 extensionId(后端的 extension_id) - id: ext.extensionId, - // 保留后端 uuid 以便需要时使用 - uuid: ext.id, - recordId: ext.recordId, - extensionId: ext.extensionId, - name: ext.name, - description: ext.description, - version: ext.version, - icon: ext.icon || '', - browser: ext.browser as ExtensionItem['browser'], - status: ext.status, - source: ext.source, - author: ext.author, - homepage: ext.homepage, - downloads: ext.downloads, - rating: ext.rating, - updatedAt: ext.updatedAt, - createdAt: ext.createdAt, - fileSize: ext.fileSize, - permissions: ext.permissions, - hash: ext.hash, - scope: ext.scope, // 保留 scope 信息 - groups: ext.groups, // 关联的分组列表 - category: ext.category, - }; -} - -/** - * 获取扩展列表的 Hook - * - * @param mode - 'all' 获取所有扩展(商店),'installed' 获取已安装扩展 - * @param scope - 范围过滤:'all' | 'user' | 'team'(仅用于 installed 模式) - */ -export function useExtensions( - mode: 'all' | 'installed' | 'local' = 'all', - scope: 'all' | 'user' | 'team' = 'all', - params?: UseExtensionsParams -): UseExtensionsReturn { - const [extensions, setExtensions] = useState([]); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchExtensions = useCallback(async () => { - setLoading(true); - setError(null); - try { - if (mode === 'installed') { - const data = await listInstalledExtensions(scope); - setExtensions(data.map(toExtensionItem)); - setTotal(data.length); - } else if (mode === 'local') { - const data = await listLocalExtensions(); - setExtensions(data.map(toExtensionItem)); - setTotal(data.length); - } else { - const data = await listExtensions({ - page: params?.page, - page_size: params?.pageSize, - search: params?.search, - category: params?.category, - sort_by: params?.sortBy, - sort_order: params?.sortOrder, - }); - setExtensions(data.items.map(toExtensionItem)); - setTotal(data.total); - } - } catch (e) { - setError(e instanceof Error ? e.message : '获取扩展列表失败'); - } finally { - setLoading(false); - } - }, [mode, params?.category, params?.page, params?.pageSize, params?.search, params?.sortBy, params?.sortOrder, scope]); - - return { - extensions, - total, - loading, - error, - refresh: fetchExtensions, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-homepage-dialog.ts b/plugins/pages/browser-extensions/src/hooks/use-homepage-dialog.ts deleted file mode 100644 index 97626eb5..00000000 --- a/plugins/pages/browser-extensions/src/hooks/use-homepage-dialog.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useState } from 'react'; -import type { ExtensionItem } from '../types'; - -interface UseHomepageDialogReturn { - homepageDialogOpen: boolean; - homepageExtension: ExtensionItem | null; - openHomepageDialog: (extension: ExtensionItem) => void; - closeHomepageDialog: () => void; -} - -/** - * 访问主页对话框状态管理 Hook - */ -export function useHomepageDialog(): UseHomepageDialogReturn { - const [homepageDialogOpen, setHomepageDialogOpen] = useState(false); - const [homepageExtension, setHomepageExtension] = useState(null); - - const openHomepageDialog = (extension: ExtensionItem) => { - setHomepageExtension(extension); - setHomepageDialogOpen(true); - }; - - const closeHomepageDialog = () => { - setHomepageDialogOpen(false); - setHomepageExtension(null); - }; - - return { - homepageDialogOpen, - homepageExtension, - openHomepageDialog, - closeHomepageDialog, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-install-dialog.ts b/plugins/pages/browser-extensions/src/hooks/use-install-dialog.ts deleted file mode 100644 index 9e8ecb98..00000000 --- a/plugins/pages/browser-extensions/src/hooks/use-install-dialog.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useState } from 'react'; -import type { StoreExtension } from '../types'; - -interface UseInstallDialogReturn { - installDialogOpen: boolean; - installingExt: StoreExtension | null; - installGroups: string[]; - installForTeam: boolean; - openInstallDialog: (extension: StoreExtension) => void; - closeInstallDialog: () => void; - toggleInstallGroup: (group: string) => void; - setInstallForTeam: (forTeam: boolean) => void; -} - -/** - * 安装对话框状态管理 Hook - */ -export function useInstallDialog(): UseInstallDialogReturn { - const [installDialogOpen, setInstallDialogOpen] = useState(false); - const [installingExt, setInstallingExt] = useState(null); - const [installGroups, setInstallGroups] = useState([]); - const [installForTeam, setInstallForTeam] = useState(false); - - const openInstallDialog = (extension: StoreExtension) => { - setInstallingExt(extension); - setInstallGroups([]); - setInstallForTeam(false); - setInstallDialogOpen(true); - }; - - const closeInstallDialog = () => { - setInstallDialogOpen(false); - setInstallingExt(null); - setInstallGroups([]); - setInstallForTeam(false); - }; - - const toggleInstallGroup = (group: string) => { - setInstallGroups((prev) => { - if (prev.includes(group)) { - return prev.filter((g) => g !== group); - } else { - return [...prev, group]; - } - }); - }; - - return { - installDialogOpen, - installingExt, - installGroups, - installForTeam, - openInstallDialog, - closeInstallDialog, - toggleInstallGroup, - setInstallForTeam, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-search-pagination.ts b/plugins/pages/browser-extensions/src/hooks/use-search-pagination.ts deleted file mode 100644 index 48be2c7d..00000000 --- a/plugins/pages/browser-extensions/src/hooks/use-search-pagination.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useState, useCallback } from 'react'; - -interface UseSearchPaginationReturn { - searchQuery: string; - currentPage: number; - setSearchQuery: (value: string) => void; - setCurrentPage: (page: number) => void; - handleSearchChange: (value: string) => void; - handlePageChange: (page: number) => void; -} - -/** - * 搜索和分页状态管理 Hook - */ -export function useSearchPagination(): UseSearchPaginationReturn { - const [searchQuery, setSearchQueryState] = useState(''); - const [currentPage, setCurrentPageState] = useState(1); - - const handleSearchChange = useCallback((value: string) => { - setSearchQueryState(value); - setCurrentPageState(1); - }, []); - - const handlePageChange = useCallback((page: number) => { - setCurrentPageState(page); - }, []); - - return { - searchQuery, - currentPage, - setSearchQuery: setSearchQueryState, - setCurrentPage: setCurrentPageState, - handleSearchChange, - handlePageChange, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-toggle-dialog.ts b/plugins/pages/browser-extensions/src/hooks/use-toggle-dialog.ts deleted file mode 100644 index e936d412..00000000 --- a/plugins/pages/browser-extensions/src/hooks/use-toggle-dialog.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { ExtensionItem } from '../types'; - -export function useToggleDialog() { - const [toggleDialogOpen, setToggleDialogOpen] = useState(false); - const [togglingExt, setTogglingExt] = useState(null); - const [toggleAction, setToggleAction] = useState<'disable' | 'enable'>('disable'); - - const openToggleDialog = useCallback((extension: ExtensionItem, action: 'disable' | 'enable') => { - setTogglingExt(extension); - setToggleAction(action); - setToggleDialogOpen(true); - }, []); - - const closeToggleDialog = useCallback(() => { - setToggleDialogOpen(false); - setTogglingExt(null); - }, []); - - return { - toggleDialogOpen, - togglingExt, - toggleAction, - openToggleDialog, - closeToggleDialog, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-uninstall-dialog.ts b/plugins/pages/browser-extensions/src/hooks/use-uninstall-dialog.ts deleted file mode 100644 index 74d5b66a..00000000 --- a/plugins/pages/browser-extensions/src/hooks/use-uninstall-dialog.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useState } from 'react'; -import type { ExtensionItem } from '../types'; - -interface UseUninstallDialogReturn { - uninstallDialogOpen: boolean; - uninstallingExt: ExtensionItem | null; - action: 'uninstall' | 'remove'; - openUninstallDialog: (extension: ExtensionItem) => void; - openRemoveDialog: (extension: ExtensionItem) => void; - closeUninstallDialog: () => void; -} - -/** - * 卸载对话框状态管理 Hook - */ -export function useUninstallDialog(): UseUninstallDialogReturn { - const [uninstallDialogOpen, setUninstallDialogOpen] = useState(false); - const [uninstallingExt, setUninstallingExt] = useState(null); - const [action, setAction] = useState<'uninstall' | 'remove'>('uninstall'); - - const openUninstallDialog = (extension: ExtensionItem) => { - setAction('uninstall'); - setUninstallingExt(extension); - setUninstallDialogOpen(true); - }; - - const openRemoveDialog = (extension: ExtensionItem) => { - setAction('remove'); - setUninstallingExt(extension); - setUninstallDialogOpen(true); - }; - - const closeUninstallDialog = () => { - setUninstallDialogOpen(false); - setUninstallingExt(null); - setAction('uninstall'); - }; - - return { - uninstallDialogOpen, - uninstallingExt, - action, - openUninstallDialog, - openRemoveDialog, - closeUninstallDialog, - }; -} diff --git a/plugins/pages/browser-extensions/src/hooks/use-view-mode.ts b/plugins/pages/browser-extensions/src/hooks/use-view-mode.ts deleted file mode 100644 index fefb75e9..00000000 --- a/plugins/pages/browser-extensions/src/hooks/use-view-mode.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useState } from 'react'; -import type { ViewMode } from '../types'; - -interface UseViewModeReturn { - viewMode: ViewMode; - setViewMode: (mode: ViewMode) => void; -} - -/** - * 视图模式管理 Hook - */ -export function useViewMode(): UseViewModeReturn { - const [viewMode, setViewMode] = useState('installed'); - - return { - viewMode, - setViewMode, - }; -} diff --git a/plugins/pages/browser-extensions/src/i18n/resources.ts b/plugins/pages/browser-extensions/src/i18n/resources.ts index d6f113ea..99019839 100644 --- a/plugins/pages/browser-extensions/src/i18n/resources.ts +++ b/plugins/pages/browser-extensions/src/i18n/resources.ts @@ -117,6 +117,7 @@ export const extensionsResources = { import: '导入插件', importing: '导入中...', install: '安装', + installSuccess: '插件已安装到本地', remove: '移除', empty: '暂无本地插件', emptyDescription: '点击“导入插件”将本地浏览器插件加入当前设备的插件库。', @@ -349,6 +350,7 @@ export const extensionsResources = { import: 'Import Plugin', importing: 'Importing...', install: 'Install', + installSuccess: 'Extension installed locally', remove: 'Remove', empty: 'No local extensions', emptyDescription: diff --git a/plugins/pages/browser-extensions/src/index.tsx b/plugins/pages/browser-extensions/src/index.tsx index 76343a9d..233ec91f 100644 --- a/plugins/pages/browser-extensions/src/index.tsx +++ b/plugins/pages/browser-extensions/src/index.tsx @@ -1,224 +1,88 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; import { extensionRegistry } from '@slotkitjs/core'; +import { Loader2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; import { extensionsResources } from './i18n/resources'; -import { useState, useEffect, useRef, useMemo } from 'react'; -import { ExtensionHeader } from './components/extension-header'; -import { ExtensionStats } from './components/extension-stats'; -import { ExtensionTable } from './components/extension-table'; -import { ExtensionBatchActions } from './components/extension-batch-actions'; -import { ExtensionPagination } from './components/extension-pagination'; -import { ExtensionStore } from './components/extension-store'; import { LocalExtensionLibrary } from './components/local-extension-library'; import { LocalExtensionImportDialog } from './components/local-extension-import-dialog'; -import { ExtensionInstallDialog } from './components/extension-install-dialog'; import { ExtensionUninstallDialog } from './components/extension-uninstall-dialog'; -import { ExtensionDetailDialog } from './components/extension-detail-dialog'; -import { ExtensionHomepageDialog } from './components/extension-homepage-dialog'; -import { ExtensionBatchUninstallDialog } from './components/extension-batch-uninstall-dialog'; import { ExtensionToggleDialog } from './components/extension-toggle-dialog'; -import { useExtensions } from './hooks/use-extensions'; -import { useExtensionFilters, useStoreExtensions } from './hooks/use-extension-filters'; -import { useExtensionPagination } from './hooks/use-extension-pagination'; -import { useExtensionStats } from './hooks/use-extension-stats'; -import { useViewMode } from './hooks/use-view-mode'; -import { useSearchPagination } from './hooks/use-search-pagination'; -import { useExtensionHandlers } from './hooks/use-extension-handlers'; +import { ExtensionDetailDialog } from './components/extension-detail-dialog'; import { + disableLocalExtension, + enableLocalExtension, importLocalExtensionCrx, importLocalExtensionStoreUrl, - listGroups, - type GroupItem, + installLocalExtension, + listLocalExtensions, + removeLocalExtension, + type Extension, } from './api'; -import { STORE_ITEMS_PER_PAGE } from './constants'; -import type { SortOption } from './types'; -import { toast } from 'sonner'; +import type { ExtensionItem } from './types'; + +function toExtensionItem(extension: Extension): ExtensionItem { + return { + id: extension.id, + uuid: extension.id, + recordId: extension.recordId, + extensionId: extension.extensionId, + name: extension.name, + description: extension.description, + version: extension.version, + icon: extension.icon || '', + browser: extension.browser as ExtensionItem['browser'], + status: extension.status, + source: 'local', + author: extension.author, + homepage: extension.homepage, + downloads: extension.downloads, + rating: extension.rating, + updatedAt: extension.updatedAt, + createdAt: extension.createdAt, + fileSize: extension.fileSize, + permissions: extension.permissions, + hash: extension.hash, + scope: extension.scope, + category: extension.category, + }; +} const BrowserExtensionsPage: React.FC = () => { const { t } = useTranslation('extensions'); - const [scopeFilter, setScopeFilter] = useState('all'); - const [importingLocal, setImportingLocal] = useState(false); - const [localImportDialogOpen, setLocalImportDialogOpen] = useState(false); - - // 分组数据 - const [groups, setGroups] = useState([]); - const [loadingGroups, setLoadingGroups] = useState(false); - - // 视图模式 - const { viewMode, setViewMode } = useViewMode(); - - // 搜索和分页 - const { searchQuery, currentPage, handleSearchChange, handlePageChange } = useSearchPagination(); - const [storeCategory, setStoreCategory] = useState('all'); - const [storePageSize, setStorePageSize] = useState(STORE_ITEMS_PER_PAGE); - const [storeSortBy, setStoreSortBy] = useState('downloads'); - - // 数据获取 - 同时获取所有扩展和已安装扩展的数据 - const allExtensionsHook = useExtensions('all', 'all', { - page: currentPage, - pageSize: storePageSize, - search: searchQuery, - category: storeCategory === 'all' ? undefined : storeCategory, - sortBy: storeSortBy, - sortOrder: storeSortBy === 'name' ? 'asc' : 'desc', - }); - // 将 scopeFilter 映射到 API 的 scope 参数:'all' -> 'all', 'team' -> 'team', 'personal' -> 'user' - const apiScope = scopeFilter === 'personal' ? 'user' : scopeFilter === 'team' ? 'team' : 'all'; - const installedExtensionsHook = useExtensions('installed', apiScope); - const localExtensionsHook = useExtensions('local'); - - // 根据视图模式选择对应的数据和刷新函数 - const { - extensions: viewExtensions, - loading, - error, - refresh, - } = viewMode === 'installed' - ? installedExtensionsHook - : viewMode === 'local' - ? localExtensionsHook - : allExtensionsHook; - - // 获取已安装扩展的数据(用于统计) - const { extensions: installedExtensions } = installedExtensionsHook; - - const refreshRef = useRef(refresh); - const skipRefreshEffectOnMountRef = useRef(true); - - // 保持 refresh 函数引用最新 - useEffect(() => { - refreshRef.current = refresh; - }, [refresh]); - - // 初始加载 - 同时加载两个数据源 - useEffect(() => { - if (viewMode === 'installed') { - void installedExtensionsHook.refresh(); - } else if (viewMode === 'local') { - void localExtensionsHook.refresh(); - } else { - void allExtensionsHook.refresh(); - } - }, []); - - // 当切换视图模式或 scopeFilter 时,重新加载对应数据 - useEffect(() => { - if (skipRefreshEffectOnMountRef.current) { - skipRefreshEffectOnMountRef.current = false; - return; - } - void refreshRef.current(); - }, [viewMode, scopeFilter]); - - useEffect(() => { - if (viewMode !== 'store') { - return; - } - void allExtensionsHook.refresh(); - }, [allExtensionsHook.refresh]); - - useEffect(() => { - if (viewMode !== 'local') { - return; - } - void localExtensionsHook.refresh(); - }, [localExtensionsHook.refresh, viewMode]); - - // 过滤 - // 注意:已安装视图模式下,API 返回的都是已安装的扩展,不需要再次过滤状态 - const allFilteredExtensions = useExtensionFilters(allExtensionsHook.extensions, { - searchQuery, - scopeFilter, - }); - - // 对于已安装视图,只进行搜索过滤(不过滤状态,因为 API 返回的都是已安装的) - const filteredExtensions = useMemo(() => { - if (viewMode === 'installed' || viewMode === 'local') { - if (!searchQuery) return viewExtensions; - const query = searchQuery.toLowerCase(); - return viewExtensions.filter( - (ext) => - ext.name.toLowerCase().includes(query) || - ext.description.toLowerCase().includes(query) || - ext.author?.toLowerCase().includes(query) - ); - } - return allFilteredExtensions; - }, [viewMode, viewExtensions, allFilteredExtensions, searchQuery]); - - // 商店扩展 - const storeExtensions = useStoreExtensions(allExtensionsHook.extensions); - - // 分页 - const { paginatedExtensions, totalPages } = useExtensionPagination( - filteredExtensions, - currentPage - ); - - // 统计 - 使用正确的数据源 - // 已安装数量:从已安装扩展数据源获取 - const installedStats = useExtensionStats(installedExtensions); - // 所有扩展数量:从所有扩展数据源获取 - const allStats = useExtensionStats(allExtensionsHook.extensions); - - // 合并统计信息 - const stats = { - installedCount: installedStats.installedCount, - updateCount: installedStats.updateCount, - availableCount: allStats.availableCount, - }; - - const refreshAfterOperation = async () => { - await Promise.all([ - refresh(), - installedExtensionsHook.refresh(), - localExtensionsHook.refresh(), - ]); - }; - - // 事件处理(整合所有操作) - const handlers = useExtensionHandlers({ - extensions: viewExtensions, - paginatedExtensions, - onRefresh: refreshAfterOperation, - }); - - // 加载分组数据 - const loadGroups = async () => { - setLoadingGroups(true); + const [extensions, setExtensions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [importing, setImporting] = useState(false); + const [importOpen, setImportOpen] = useState(false); + const [removeTarget, setRemoveTarget] = useState(null); + const [toggleTarget, setToggleTarget] = useState(null); + const [toggleAction, setToggleAction] = useState<'disable' | 'enable'>('disable'); + const [detailTarget, setDetailTarget] = useState(null); + + const refresh = useCallback(async () => { + setError(null); try { - const data = await listGroups(); - setGroups(data); - } catch (e) { - console.error('Failed to load groups:', e); + setExtensions((await listLocalExtensions()).map(toExtensionItem)); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); } finally { - setLoadingGroups(false); + setLoading(false); } - }; - - // 当安装弹窗打开时加载分组数据 - useEffect(() => { - if (handlers.installDialog.installDialogOpen) { - void loadGroups(); - } - }, [handlers.installDialog.installDialogOpen]); + }, []); useEffect(() => { - handlePageChange(1); - }, [handlePageChange, searchQuery, storeCategory, storePageSize, storeSortBy, viewMode]); - - const handleStoreCategoryChange = (category: string) => { - setStoreCategory(category); - }; + void refresh(); + }, [refresh]); - const handleStorePageSizeChange = (size: number) => { - setStorePageSize(size); - }; + const byId = useMemo( + () => new Map(extensions.map((extension) => [extension.id, extension])), + [extensions] + ); - const handleStoreSortChange = (sort: SortOption) => { - setStoreSortBy(sort); - }; + const recordId = (extension: ExtensionItem) => extension.recordId || extension.id; - const handleImportLocal = async ({ + const handleImport = async ({ mode, crxPath, storeUrl, @@ -227,19 +91,13 @@ const BrowserExtensionsPage: React.FC = () => { crxPath?: string; storeUrl?: string; }) => { + setImporting(true); try { - setImportingLocal(true); const result = mode === 'file' ? await importLocalExtensionCrx(crxPath || '') : await importLocalExtensionStoreUrl(storeUrl || ''); - - if (result.importState === 'imported') { - await Promise.all([localExtensionsHook.refresh(), installedExtensionsHook.refresh()]); - } - - setLocalImportDialogOpen(false); - + setImportOpen(false); if (result.importState === 'alreadyInstalled') { toast.info(t('dialog.localImport.alreadyInstalled')); } else if (result.importState === 'alreadyExists') { @@ -247,218 +105,118 @@ const BrowserExtensionsPage: React.FC = () => { } else { toast.success(t('dialog.localImport.success')); } - - if (viewMode !== 'local') { - setViewMode('local'); - } - } catch (e) { - toast.error(e instanceof Error ? e.message : '导入本地插件失败'); + await refresh(); } finally { - setImportingLocal(false); + setImporting(false); } }; - return ( -
- - - {viewMode === 'installed' ? ( - <> - - - {error && ( -
- {t('error', { message: error })} -
- )} - - handlers.selection.selectAll(paginatedExtensions, selected)} - onUpdate={handlers.handleUpdate} - onUninstall={handlers.handleUninstall} - onViewDetails={handlers.handleViewDetails} - onHomepage={handlers.handleHomepage} - onSecurityCheck={handlers.handleSecurityCheck} - onDisable={handlers.handleDisable} - onEnable={handlers.handleEnable} - loading={loading} - /> + const handleInstall = async (extension: ExtensionItem) => { + await installLocalExtension(recordId(extension)); + toast.success(t('local.installSuccess')); + await refresh(); + }; - + const confirmRemove = async () => { + if (!removeTarget) return; + await removeLocalExtension(recordId(removeTarget)); + setRemoveTarget(null); + await refresh(); + }; - {}} - onBatchUpdate={handlers.handleBatchUpdate} - onBatchUninstall={handlers.handleBatchUninstall} - onClearSelection={handlers.selection.clearSelection} - /> - - ) : viewMode === 'store' ? ( - <> - {error && ( -
- {t('error', { message: error })} -
- )} + const confirmToggle = async () => { + if (!toggleTarget) return; + const command = toggleAction === 'disable' ? disableLocalExtension : enableLocalExtension; + await command(recordId(toggleTarget)); + setToggleTarget(null); + await refresh(); + }; - - + return ( +
+ {loading ? ( +
+ +
) : ( <> {error && ( -
+
{t('error', { message: error })}
)} - setLocalImportDialogOpen(true)} - onInstall={(extension) => handlers.handleInstall(extension.id)} - onDisable={(extension) => handlers.handleDisable(extension.id, extension.name)} - onEnable={(extension) => handlers.handleEnable(extension.id, extension.name)} - onRemove={(extension) => handlers.handleRemove(extension.id, extension.name)} - onViewDetails={(extension) => handlers.handleViewDetails(extension.id)} + extensions={extensions} + importing={importing} + onImport={() => setImportOpen(true)} + onInstall={(extension) => void handleInstall(byId.get(extension.id) || extension)} + onDisable={(extension) => { + setToggleTarget(extension); + setToggleAction('disable'); + }} + onEnable={(extension) => { + setToggleTarget(extension); + setToggleAction('enable'); + }} + onRemove={setRemoveTarget} + onViewDetails={setDetailTarget} /> )} - {/* 安装对话框 */} - - - - {/* 卸载确认对话框 */} - - {/* 详情对话框 */} - - - {/* 访问主页对话框 */} - !open && setRemoveTarget(null)} + onConfirm={confirmRemove} /> - - {/* 批量卸载确认对话框 */} - - - {/* 禁用/启用确认对话框 */} !open && setToggleTarget(null)} + onConfirm={confirmToggle} + /> + !open && setDetailTarget(null)} />
); }; -// 在模块加载时贡献路由 try { extensionRegistry.contribute('routes', { contributorId: 'browser-extensions', - value: { - path: '/extensions', - Component: BrowserExtensionsPage, - }, + value: { path: '/extensions', Component: BrowserExtensionsPage }, priority: 10, }); - console.log('[browser-extensions] Route contributed at module load: /extensions'); } catch (error) { - console.warn('[browser-extensions] Failed to contribute route at module load:', error); + console.warn('[browser-extensions] Failed to contribute route:', error); } try { extensionRegistry.contribute('i18n:resources', { contributorId: 'browser-extensions', - value: { - namespace: 'extensions', - resources: extensionsResources, - }, + value: { namespace: 'extensions', resources: extensionsResources }, priority: 10, }); } catch (error) { console.warn('[browser-extensions] Failed to contribute i18n resources:', error); } -const browserExtensionsPlugin = { +export default { id: 'browser-extensions', name: 'Browser Extensions', version: '1.0.0', component: BrowserExtensionsPage, slots: [], }; - -export default browserExtensionsPlugin; diff --git a/plugins/pages/browser-extensions/src/types/index.ts b/plugins/pages/browser-extensions/src/types/index.ts index ec173b88..ca97a5bf 100644 --- a/plugins/pages/browser-extensions/src/types/index.ts +++ b/plugins/pages/browser-extensions/src/types/index.ts @@ -1,8 +1,5 @@ export interface ExtensionItem { - /** - * 用于业务操作的唯一 ID。 - * 远端插件使用 extension_id,本地插件使用 record_id。 - */ + /** 用于本地扩展业务操作的唯一 ID。 */ id: string; /** * 后端扩展记录的 UUID(仅用于展示/调试,不用于接口调用) @@ -16,7 +13,7 @@ export interface ExtensionItem { status: 'installed' | 'available' | 'update' | 'disabled' | 'active'; icon?: string; browser: 'chrome' | 'firefox' | 'edge' | 'all'; - source: 'remote' | 'local'; + source: 'local'; author?: string; homepage?: string; downloads?: number; @@ -34,12 +31,6 @@ export interface ExtensionItem { category?: string; } -export interface StoreExtension extends ExtensionItem { - category?: string; - rating?: number; - isInstalled?: boolean; -} - export type ExtensionStatus = 'installed' | 'available' | 'update' | 'disabled' | 'active'; export type ExtensionBrowser = 'chrome' | 'firefox' | 'edge' | 'all'; export type ExtensionCategory = @@ -49,5 +40,3 @@ export type ExtensionCategory = | 'tools' | 'media' | 'social'; -export type ViewMode = 'installed' | 'store' | 'local'; -export type SortOption = 'downloads' | 'rating' | 'name' | 'newest'; diff --git a/plugins/pages/browser-extensions/src/utils/chrome-store.ts b/plugins/pages/browser-extensions/src/utils/chrome-store.ts deleted file mode 100644 index 95fb33a4..00000000 --- a/plugins/pages/browser-extensions/src/utils/chrome-store.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 根据扩展 ID 生成 Chrome Web Store URL - * - * @param extensionId - Chrome 扩展 ID - * @returns Chrome Web Store 页面 URL - */ -export function getChromeStoreUrl(extensionId: string): string { - return `https://chromewebstore.google.com/detail/${extensionId}`; -} - -/** - * 获取扩展的主页 URL - * 如果扩展有 homepage,则使用 homepage;否则使用 Chrome Web Store URL - * - * @param extensionId - Chrome 扩展 ID - * @param homepage - 扩展的主页 URL(可选) - * @returns 实际的主页 URL - */ -export function getExtensionHomepageUrl(extensionId: string, homepage?: string | null): string { - return homepage || getChromeStoreUrl(extensionId); -} diff --git a/plugins/pages/create-window/src/api/index.ts b/plugins/pages/create-window/src/api/index.ts index 2cf8aae9..6d3349c2 100644 --- a/plugins/pages/create-window/src/api/index.ts +++ b/plugins/pages/create-window/src/api/index.ts @@ -30,8 +30,8 @@ export const API_ENDPOINTS = { /** 浏览器内核版本 */ export interface BrowserKernelVersion { - id: number; - type_id: number; + kernel_id: string; + type_code: string; resource_name: string; version: string; name?: string; @@ -44,6 +44,7 @@ export interface BrowserKernelVersion { requires_extract?: boolean; entrypoint_template?: string; extract_root?: string; + installed?: boolean; } function buildEnvironmentUrls(urls: string[]) { @@ -70,6 +71,7 @@ function buildWindowInfoPayload(config: WindowConfig['windowInfo']) { name: config.name, system: config.system, kernel: config.kernel, + kernel_id: config.kernelId || undefined, userAgent: config.userAgent, searchEngine: config.searchEngine, description: config.description, diff --git a/plugins/pages/create-window/src/components/import-dialog.tsx b/plugins/pages/create-window/src/components/import-dialog.tsx index fe095aca..5fb781d8 100644 --- a/plugins/pages/create-window/src/components/import-dialog.tsx +++ b/plugins/pages/create-window/src/components/import-dialog.tsx @@ -8,8 +8,9 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { isSuccess, post } from '@/lib/request'; interface ImportDialogProps { open: boolean; @@ -31,18 +32,15 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) { const data = JSON.parse(importJson); setSubmitting(true); - const response = await fetch('/api/v1/environments/import', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ environments: Array.isArray(data) ? data : [data] }), - }); - - if (!response.ok) { - throw new Error('导入失败'); - } - - const json = await response.json(); - toast.success(`成功导入 ${json.data?.length || 0} 个窗口`); + const environments = Array.isArray(data) ? data : [data]; + const response = await post('environments/batch-create', { environments }); + + if (!isSuccess(response)) { + throw new Error(response.message || '导入失败'); + } + + const imported = Array.isArray(response.data) ? response.data.length : environments.length; + toast.success(`成功导入 ${imported} 个窗口`); setImportJson(''); onOpenChange(false); } catch (error) { diff --git a/plugins/pages/create-window/src/components/proxy-select-dialog.tsx b/plugins/pages/create-window/src/components/proxy-select-dialog.tsx deleted file mode 100644 index 959e1d3e..00000000 --- a/plugins/pages/create-window/src/components/proxy-select-dialog.tsx +++ /dev/null @@ -1,491 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; -import { Checkbox } from '@/components/animate-ui/components/radix/checkbox'; -import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, - PaginationEllipsis, -} from '@/components/ui/pagination'; -import { Network, Zap, Monitor } from 'lucide-react'; -// 代理类型定义(从代理中心复制) -interface Proxy { - id: string; - name: string; - host: string; - port: number; - type: 'http' | 'https' | 'socks5'; - username?: string; - password?: string; - country: string; - latency: number; - status: 'healthy' | 'unreachable' | 'testing'; - usageCount: number; - linkedEnvironments: number; - createdAt: string; - lastChecked: string; -} - -const API_ENDPOINTS = { - PROXIES: '/api/v1/proxies', -} as const; - -const ITEMS_PER_PAGE = 10; - -interface ProxySelectDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - createCount: number; - onSelect: (proxies: Proxy[]) => void; -} - -/** - * 代理选择对话框组件 - */ -export function ProxySelectDialog({ - open, - onOpenChange, - createCount, - onSelect, -}: ProxySelectDialogProps) { - const { t } = useTranslation('proxy'); - const [proxies, setProxies] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [currentPage, setCurrentPage] = useState(1); - const [selectedProxyIds, setSelectedProxyIds] = useState>(new Set()); - - // 是否支持多选 - const allowMultiple = createCount > 1; - - // 获取代理列表 - const fetchProxies = useCallback(async () => { - setLoading(true); - setError(null); - try { - const res = await fetch(API_ENDPOINTS.PROXIES); - if (!res.ok) throw new Error(`请求失败: ${res.status}`); - const json = (await res.json()) as { data: Proxy[] }; - setProxies(json.data ?? []); - } catch (e) { - setError(e instanceof Error ? e.message : '未知错误'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - if (open) { - void fetchProxies(); - setCurrentPage(1); - setSelectedProxyIds(new Set()); - } - }, [open, fetchProxies]); - - // 分页计算 - const totalPages = Math.ceil(proxies.length / ITEMS_PER_PAGE); - const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; - const endIndex = startIndex + ITEMS_PER_PAGE; - const paginatedProxies = proxies.slice(startIndex, endIndex); - - // 格式化日期 - const formatDate = (dateString: string) => { - if (!dateString || dateString === '-') return '-'; - try { - const date = new Date(dateString); - return date.toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }); - } catch { - return dateString; - } - }; - - // 获取状态徽章 - const getStatusBadge = (status: Proxy['status']) => { - switch (status) { - case 'healthy': - return ( - - - {t('status.healthy')} - - ); - case 'unreachable': - return ( - - - {t('status.unreachable')} - - ); - case 'testing': - return ( - - - {t('status.testing')} - - ); - default: - return null; - } - }; - - // 获取类型徽章 - const getTypeBadge = (type: Proxy['type']) => { - const colors: Record = { - http: 'bg-blue-500/10 text-blue-500', - https: 'bg-green-500/10 text-green-500', - socks5: 'bg-purple-500/10 text-purple-500', - }; - return ( - - {type} - - ); - }; - - // 获取页码数组 - const getPageNumbers = () => { - const pages: (number | 'ellipsis')[] = []; - const maxVisible = 7; - - if (totalPages <= maxVisible) { - for (let i = 1; i <= totalPages; i++) { - pages.push(i); - } - } else { - if (currentPage <= 3) { - for (let i = 1; i <= 4; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(totalPages); - } else if (currentPage >= totalPages - 2) { - pages.push(1); - pages.push('ellipsis'); - for (let i = totalPages - 3; i <= totalPages; i++) pages.push(i); - } else { - pages.push(1); - pages.push('ellipsis'); - for (let i = currentPage - 1; i <= currentPage + 1; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(totalPages); - } - } - - return pages; - }; - - // 处理选择代理 - const handleSelectProxy = (proxy: Proxy) => { - if (allowMultiple) { - // 多选模式:切换选择状态 - const newSelectedIds = new Set(selectedProxyIds); - if (newSelectedIds.has(proxy.id)) { - newSelectedIds.delete(proxy.id); - } else { - newSelectedIds.add(proxy.id); - } - setSelectedProxyIds(newSelectedIds); - } else { - // 单选模式:选择新代理时取消之前的选择,只保留当前选择的 - setSelectedProxyIds(new Set([proxy.id])); - } - }; - - // 处理全选/取消全选 - const handleSelectAll = (selected: boolean) => { - if (!allowMultiple) return; - - if (selected) { - const allIds = new Set(proxies.map((p) => p.id)); - setSelectedProxyIds(allIds); - } else { - setSelectedProxyIds(new Set()); - } - }; - - // 处理确认选择 - const handleConfirmSelection = () => { - if (selectedProxyIds.size === 0) return; - - const selectedProxies = proxies.filter((p) => selectedProxyIds.has(p.id)); - onSelect(selectedProxies); - onOpenChange(false); - }; - - // 计算全选状态 - const allSelected = - allowMultiple && proxies.length > 0 && proxies.every((proxy) => selectedProxyIds.has(proxy.id)); - const someSelected = allowMultiple && proxies.some((proxy) => selectedProxyIds.has(proxy.id)); - - return ( - - - - - {t('dialog.select.title', { defaultValue: '选择代理' })} - - - -
- -
- - - - - - - - - - - - - - - - - {loading ? ( - - - - ) : error ? ( - - - - ) : paginatedProxies.length === 0 ? ( - - - - ) : ( - paginatedProxies.map((proxy) => ( - handleSelectProxy(proxy)} - > - - - - - - - - - - - - )) - )} - -
-
- {allowMultiple ? ( - { - if (input && 'indeterminate' in input) { - (input as HTMLInputElement).indeterminate = - someSelected && !allSelected; - } - }} - onCheckedChange={handleSelectAll} - className="h-4 w-4" - /> - ) : ( - - )} -
-
- {t('table.headers.name')} - - {t('table.headers.address')} - - {t('table.headers.type')} - - {t('table.headers.country')} - - {t('table.headers.latency')} - - {t('table.headers.status')} - - {t('table.headers.linkedEnvironments')} - - {t('table.headers.usageCount')} - - {t('table.headers.createdAt')} -
- {t('table.loading', { defaultValue: '加载中...' })} -
- {error} -
- {t('table.empty')} -
-
- handleSelectProxy(proxy)} - className="h-4 w-4" - onClick={(e) => e.stopPropagation()} - /> -
-
-
- - {proxy.name} -
-
- ID: {proxy.id} -
-
-
- {proxy.host}:{proxy.port} -
- {proxy.username && ( -
- {t('table.auth')}: {proxy.username} -
- )} -
- {getTypeBadge(proxy.type)} - - {proxy.country} - - {proxy.status === 'healthy' ? ( -
- - {proxy.latency} ms -
- ) : ( - - - )} -
- {getStatusBadge(proxy.status)} - -
- - - {proxy.linkedEnvironments} - -
-
- - {proxy.usageCount} - - -
- {formatDate(proxy.createdAt)} -
-
-
- -
- - {/* 确认选择按钮 */} - {selectedProxyIds.size > 0 && ( -
-
- {allowMultiple ? `已选择 ${selectedProxyIds.size} 个代理` : '已选择 1 个代理'} -
-
- {allowMultiple && ( - - )} - -
-
- )} - - {/* 分页 */} - {!loading && !error && proxies.length > 0 && ( -
-
- {t('pagination.pageInfo', { currentPage, totalPages })} -
- - - - { - e.preventDefault(); - if (currentPage > 1) setCurrentPage(currentPage - 1); - }} - className={ - currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer' - } - > - {t('pagination.previous')} - - - - {getPageNumbers().map((page, index) => ( - - {page === 'ellipsis' ? ( - - ) : ( - { - e.preventDefault(); - setCurrentPage(page); - }} - isActive={page === currentPage} - className="cursor-pointer" - > - {page} - - )} - - ))} - - - { - e.preventDefault(); - if (currentPage < totalPages) setCurrentPage(currentPage + 1); - }} - className={ - currentPage === totalPages - ? 'pointer-events-none opacity-50' - : 'cursor-pointer' - } - > - {t('pagination.next')} - - - - -
- )} -
-
-
- ); -} diff --git a/plugins/pages/create-window/src/components/window-info-form.tsx b/plugins/pages/create-window/src/components/window-info-form.tsx index 1eaa4092..12125060 100644 --- a/plugins/pages/create-window/src/components/window-info-form.tsx +++ b/plugins/pages/create-window/src/components/window-info-form.tsx @@ -63,11 +63,13 @@ interface WindowInfoFormProps { * 优先从 name 字段提取版本号(如 "simprint-browser-144.0.7559.118.zip"), * 如果提取失败则使用 version 字段 */ -function getCurrentKernelVersion( - kernelVersions: BrowserKernelVersion[], - currentKernel: string -): string | undefined { - const kernel = kernelVersions.find((v) => v.resource_name === currentKernel); +function getCurrentKernelVersion( + kernelVersions: BrowserKernelVersion[], + currentKernelIdOrName: string +): string | undefined { + const kernel = kernelVersions.find( + (v) => v.kernel_id === currentKernelIdOrName || v.resource_name === currentKernelIdOrName + ); if (!kernel) { return undefined; } @@ -132,16 +134,23 @@ export function WindowInfoForm({ value, onChange }: WindowInfoFormProps) { setKernelVersions(versions); // 首次加载且 kernel 不在列表中时,设为第一个版本 if (versions.length > 0) { - const currentInList = versions.some((v) => v.resource_name === value.kernel); + const currentInList = versions.some( + (v) => v.kernel_id === value.kernelId || (!value.kernelId && v.resource_name === value.kernel) + ); const needDefault = !hasSetDefaultKernel.current[platform] && (!currentInList || value.kernel === 'Chrome'); if (needDefault) { hasSetDefaultKernel.current[platform] = true; - const firstResourceName = versions[0].resource_name; - const kernelVersion = versions[0].version; - const newUA = generateUserAgentByKernel(value.system, 'Chrome', kernelVersion); - onChange({ ...value, kernel: firstResourceName, userAgent: newUA }); + const firstResourceName = versions[0].resource_name; + const kernelVersion = versions[0].version; + const newUA = generateUserAgentByKernel(value.system, 'Chrome', kernelVersion); + onChange({ + ...value, + kernel: firstResourceName, + kernelId: versions[0].kernel_id, + userAgent: newUA, + }); } } }) @@ -157,10 +166,17 @@ export function WindowInfoForm({ value, onChange }: WindowInfoFormProps) { toast.info(t('windowInfo.firefoxNotSupported')); }; - const handleKernelVersionChange = (resourceName: string) => { - const kernelVersion = getCurrentKernelVersion(kernelVersions, resourceName); - const newUA = generateUserAgentByKernel(value.system, 'Chrome', kernelVersion); - onChange({ ...value, kernel: resourceName, userAgent: newUA }); + const handleKernelVersionChange = (kernelId: string) => { + const selected = kernelVersions.find((kernel) => kernel.kernel_id === kernelId); + if (!selected) return; + const kernelVersion = getCurrentKernelVersion(kernelVersions, kernelId); + const newUA = generateUserAgentByKernel(value.system, 'Chrome', kernelVersion); + onChange({ + ...value, + kernel: selected.resource_name, + kernelId: selected.kernel_id, + userAgent: newUA, + }); }; // 加载账号列表(用于显示) @@ -247,7 +263,10 @@ export function WindowInfoForm({ value, onChange }: WindowInfoFormProps) { key={option.value} type="button" onClick={() => { - const kernelVersion = getCurrentKernelVersion(kernelVersions, value.kernel); + const kernelVersion = getCurrentKernelVersion( + kernelVersions, + value.kernelId || value.kernel + ); const newUA = generateUserAgentByKernel(option.value, 'Chrome', kernelVersion); onChange({ ...value, system: option.value, userAgent: newUA }); }} @@ -303,10 +322,12 @@ export function WindowInfoForm({ value, onChange }: WindowInfoFormProps) { {t('windowInfo.firefoxNotSupported')} { - setPassword(e.target.value); - clearErrors(); - }} - aria-invalid={!!errors.password} - className="pl-9" - required - /> -
- {errors.password &&

{errors.password}

} -
-
- setRememberPassword(checked === true)} - /> - -
- -
-
- -
- - -
- - - ); -}; +import { useEffect, useState, type FormEvent } from 'react'; +import { useNavigate } from 'react-router'; +import { ArrowLeft, LoaderCircle, LockKeyhole, Plus, UserRound } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { useAuth } from '../../../../services/store/src'; +import { listLocalUsers, loginLocalUser, type LocalUserProfile } from '../api'; + +function toStoreUser(user: LocalUserProfile) { + return { + uuid: user.uuid, + id: user.uuid, + nickname: user.nickname, + avatar: user.avatar, + has_password: user.hasPassword, + status: 'active', + current_workspace_uuid: user.currentWorkspaceUuid ?? null, + current_team_uuid: user.currentTeamUuid ?? null, + }; +} + +export const LoginForm: React.FC = () => { + const navigate = useNavigate(); + const { t } = useTranslation('auth'); + const { setUser } = useAuth(); + const [users, setUsers] = useState([]); + const [selectedUser, setSelectedUser] = useState(null); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + listLocalUsers() + .then(setUsers) + .catch((reason) => toast.error(String(reason))) + .finally(() => setLoading(false)); + }, []); + + const finishLogin = (user: LocalUserProfile) => { + setUser(toStoreUser(user)); + navigate('/'); + }; + + const chooseUser = async (user: LocalUserProfile) => { + setError(''); + if (user.hasPassword) { + setSelectedUser(user); + setPassword(''); + return; + } + setSubmitting(true); + try { + finishLogin(await loginLocalUser(user.uuid)); + } catch (reason) { + toast.error(reason instanceof Error ? reason.message : String(reason)); + } finally { + setSubmitting(false); + } + }; + + const submitPassword = async (event: FormEvent) => { + event.preventDefault(); + if (!selectedUser || !password) return; + setSubmitting(true); + setError(''); + try { + finishLogin(await loginLocalUser(selectedUser.uuid, password)); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setSubmitting(false); + } + }; + + if (selectedUser) { + return ( + <> +
+
+ {selectedUser.avatar} +
+

{t('password.title')}

+

{t('password.subtitle')}

+
+
+
+ +
+ {selectedUser.avatar} + {selectedUser.nickname} +
+
+
+ +
+ + { + setPassword(event.target.value); + setError(''); + }} + placeholder={t('password.passwordPlaceholder')} + className="pl-9" + aria-invalid={!!error} + /> +
+ {error &&

{error}

} +
+
+ + +
+
+ + ); + } + + return ( + <> +
+

{t('login.title')}

+

{t('login.subtitle')}

+
+
+ {loading ? ( +
+ +
+ ) : users.length === 0 ? ( +
+ +

{t('login.empty')}

+
+ ) : ( +
+ {users.map((user) => ( + + ))} +
+ )} + +
+ + ); +}; diff --git a/plugins/pages/login/src/constants.ts b/plugins/pages/login/src/constants.ts deleted file mode 100644 index 8fcd421f..00000000 --- a/plugins/pages/login/src/constants.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * 邮箱验证正则 - * - 用户名部分:允许大小写字母、数字、点、连字符、下划线 - * - 域名部分:只允许小写字母、数字、连字符 - */ -export const EMAIL_REGEX = - /^[^\s@]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; diff --git a/plugins/pages/login/src/hooks/use-login-form.ts b/plugins/pages/login/src/hooks/use-login-form.ts deleted file mode 100644 index 9b16da71..00000000 --- a/plugins/pages/login/src/hooks/use-login-form.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { LoginFormData } from '../types'; - -interface UseLoginFormReturn { - formData: LoginFormData; - setEmail: (email: string) => void; - setPassword: (password: string) => void; - setRememberPassword: (remember: boolean) => void; - resetForm: () => void; -} - -/** - * 登录表单状态管理 Hook - */ -export function useLoginForm(): UseLoginFormReturn { - const [formData, setFormData] = useState({ - email: '', - password: '', - rememberPassword: false, - }); - - const setEmail = useCallback((email: string) => { - setFormData((prev) => ({ ...prev, email })); - }, []); - - const setPassword = useCallback((password: string) => { - setFormData((prev) => ({ ...prev, password })); - }, []); - - const setRememberPassword = useCallback((remember: boolean) => { - setFormData((prev) => ({ ...prev, rememberPassword: remember })); - }, []); - - const resetForm = useCallback(() => { - setFormData({ - email: '', - password: '', - rememberPassword: false, - }); - }, []); - - return { - formData, - setEmail, - setPassword, - setRememberPassword, - resetForm, - }; -} diff --git a/plugins/pages/login/src/hooks/use-login-validation.ts b/plugins/pages/login/src/hooks/use-login-validation.ts deleted file mode 100644 index 93b279e8..00000000 --- a/plugins/pages/login/src/hooks/use-login-validation.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { EMAIL_REGEX } from '../constants'; - -interface ValidationErrors { - email: string; - password: string; -} - -interface UseLoginValidationReturn { - errors: ValidationErrors; - validateEmail: (email: string) => boolean; - validatePassword: (password: string) => boolean; - validateForm: (email: string, password: string) => boolean; - clearErrors: () => void; - setEmailError: (error: string) => void; - setPasswordError: (error: string) => void; -} - -/** - * 登录表单验证 Hook - */ -export function useLoginValidation(): UseLoginValidationReturn { - const { t } = useTranslation('auth'); - const [errors, setErrors] = useState({ - email: '', - password: '', - }); - - const validateEmail = useCallback( - (email: string): boolean => { - if (!email) { - setErrors((prev) => ({ ...prev, email: t('login.err.emailRequired') })); - return false; - } - - if (!EMAIL_REGEX.test(email)) { - setErrors((prev) => ({ ...prev, email: t('login.err.emailInvalid') })); - return false; - } - - setErrors((prev) => ({ ...prev, email: '' })); - return true; - }, - [t] - ); - - const validatePassword = useCallback( - (password: string): boolean => { - if (!password) { - setErrors((prev) => ({ ...prev, password: t('login.passwordPlaceholder') })); - return false; - } - - setErrors((prev) => ({ ...prev, password: '' })); - return true; - }, - [t] - ); - - const validateForm = useCallback( - (email: string, password: string): boolean => { - const isEmailValid = validateEmail(email); - const isPasswordValid = validatePassword(password); - return isEmailValid && isPasswordValid; - }, - [validateEmail, validatePassword] - ); - - const clearErrors = useCallback(() => { - setErrors({ - email: '', - password: '', - }); - }, []); - - const setEmailError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, email: error })); - }, []); - - const setPasswordError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, password: error })); - }, []); - - return { - errors, - validateEmail, - validatePassword, - validateForm, - clearErrors, - setEmailError, - setPasswordError, - }; -} diff --git a/plugins/pages/login/src/hooks/use-login.ts b/plugins/pages/login/src/hooks/use-login.ts deleted file mode 100644 index f1ec5299..00000000 --- a/plugins/pages/login/src/hooks/use-login.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useCallback } from 'react'; -import { useNavigate } from 'react-router'; -import { toast } from 'sonner'; -import { useTranslation } from 'react-i18next'; -import { useAuth, useLoading } from '../../../../services/store/src'; -import type { LoginFormData } from '../types'; -import { login, saveRememberedCredential, clearRememberedCredential } from '../api'; - -interface UseLoginReturn { - handleLogin: (formData: LoginFormData) => Promise; -} - -/** - * 登录操作 Hook - */ -export function useLogin(): UseLoginReturn { - const navigate = useNavigate(); - const { setUser } = useAuth(); - const { setLoading } = useLoading(); - const { t } = useTranslation('auth'); - - const handleLogin = useCallback( - async (formData: LoginFormData) => { - setLoading(true); - - try { - const responseData = await login(formData.email, formData.password); - - // 根据"记住密码"选项处理凭证 - if (formData.rememberPassword && responseData.refresh_token) { - // 如果勾选了"记住密码",保存 refresh_token - try { - await saveRememberedCredential(formData.email, responseData.refresh_token); - } catch (error) { - console.warn('保存记住的凭证失败:', error); - // 不影响登录流程,只记录警告 - } - } else { - // 如果未勾选"记住密码",清除已保存的凭证 - await clearRememberedCredential(); - } - - // 更新 store 中的用户状态 - if (responseData.user_info) { - const userInfo = responseData.user_info; - setUser({ - uuid: userInfo.uuid || '', - id: userInfo.id || '', - nickname: userInfo.nickname, - email: userInfo.email || formData.email, - phone: userInfo.phone, - avatar: userInfo.avatar_hash, - status: userInfo.status || 'active', - current_workspace_uuid: userInfo.current_workspace?.uuid || null, - current_team_uuid: userInfo.current_team?.uuid || null, - }); - } else { - // 如果没有用户信息,使用邮箱作为基本信息 - setUser({ - uuid: '', - id: formData.email.split('@')[0], - email: formData.email, - status: 'active', - current_workspace_uuid: null, - current_team_uuid: null, - }); - } - - // 登录成功后跳转到首页 - navigate('/'); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : t('login.err.failed'); - toast.error(message); - throw error; - } finally { - setLoading(false); - } - }, - [navigate, setUser, setLoading, t] - ); - - return { - handleLogin, - }; -} diff --git a/plugins/pages/login/src/i18n/resources.ts b/plugins/pages/login/src/i18n/resources.ts index 78108994..dcc2f33c 100644 --- a/plugins/pages/login/src/i18n/resources.ts +++ b/plugins/pages/login/src/i18n/resources.ts @@ -1,68 +1,68 @@ -export const authResources = { - 'zh-CN': { - login: { - title: '欢迎回来', - subtitle: '登录您的账户以继续', - emailLabel: '邮箱地址', - emailPlaceholder: '请输入您的邮箱', - passwordLabel: '密码', - passwordPlaceholder: '请输入您的密码', - remember: '记住密码', - forgot: '忘记密码?', - submit: '登录', - register: '注册', - err: { - emailRequired: '请输入邮箱地址', - emailInvalid: '请输入有效的邮箱地址', - failed: '登录失败,请检查您的凭据', - failedGeneric: '登录失败', - }, - }, - register: { - title: '创建账户', - subtitle: '注册新账户以开始使用', - }, - reset: { - title: '重置密码', - subtitleStep1: '通过邮箱重置您的密码', - subtitleStep2: '请输入验证码和新密码', - backToLogin: '返回登录', - sendCode: '发送验证码', - emailPlaceholder: '请输入您的注册邮箱', - hint: '我们将向您的邮箱发送验证码,请查收邮件并输入验证码。', - }, - }, - 'en-US': { - login: { - title: 'Welcome back', - subtitle: 'Sign in to continue', - emailLabel: 'Email', - emailPlaceholder: 'Enter your email', - passwordLabel: 'Password', - passwordPlaceholder: 'Enter your password', - remember: 'Remember me', - forgot: 'Forgot password?', - submit: 'Sign in', - register: 'Sign up', - err: { - emailRequired: 'Email is required', - emailInvalid: 'Please enter a valid email', - failed: 'Sign-in failed. Please check your credentials.', - failedGeneric: 'Sign-in failed', - }, - }, - register: { - title: 'Create account', - subtitle: 'Sign up to get started', - }, - reset: { - title: 'Reset password', - subtitleStep1: 'Reset your password via email', - subtitleStep2: 'Enter the code and your new password', - backToLogin: 'Back to login', - sendCode: 'Send code', - emailPlaceholder: 'Enter your registered email', - hint: 'We will email you a verification code. Please enter it below.', - }, - }, -} as const; +export const authResources = { + 'zh-CN': { + login: { + title: '选择本地用户', + subtitle: '选择一个用户以继续使用 Simprint', + empty: '还没有本地用户', + passwordProtected: '需要密码', + directEntry: '点击直接进入', + create: '创建本地用户', + }, + password: { + title: '输入密码', + subtitle: '验证本地用户后继续', + userLabel: '本地用户', + passwordLabel: '密码', + passwordPlaceholder: '请输入密码', + back: '返回选择用户', + submit: '进入 Simprint', + }, + register: { + title: '创建本地用户', + subtitle: '数据将保存在此设备上', + nicknameLabel: '用户昵称', + nicknamePlaceholder: '请输入用户昵称', + nicknameRequired: '请输入用户昵称', + randomAvatar: '随机更换图标', + randomNickname: '生成随机昵称', + passwordLabel: '密码', + passwordPlaceholder: '密码(可选)', + passwordHint: '留空时,点击该用户即可直接进入。', + back: '返回选择用户', + submit: '创建并进入', + }, + }, + 'en-US': { + login: { + title: 'Choose a local user', + subtitle: 'Select a user to continue to Simprint', + empty: 'No local users yet', + passwordProtected: 'Password required', + directEntry: 'Click to enter', + create: 'Create local user', + }, + password: { + title: 'Enter password', + subtitle: 'Verify the local user to continue', + userLabel: 'Local user', + passwordLabel: 'Password', + passwordPlaceholder: 'Enter password', + back: 'Back to user selection', + submit: 'Enter Simprint', + }, + register: { + title: 'Create local user', + subtitle: 'Data will stay on this device', + nicknameLabel: 'Nickname', + nicknamePlaceholder: 'Enter a nickname', + nicknameRequired: 'Nickname is required', + randomAvatar: 'Choose another random icon', + randomNickname: 'Generate a random nickname', + passwordLabel: 'Password', + passwordPlaceholder: 'Password (optional)', + passwordHint: 'Leave empty to enter directly when this user is selected.', + back: 'Back to user selection', + submit: 'Create and enter', + }, + }, +} as const; diff --git a/plugins/pages/login/src/types/index.ts b/plugins/pages/login/src/types/index.ts deleted file mode 100644 index d29e5eab..00000000 --- a/plugins/pages/login/src/types/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * 登录表单数据 - */ -export interface LoginFormData { - email: string; - password: string; - rememberPassword: boolean; -} diff --git a/plugins/pages/plan-selection/manifest.json b/plugins/pages/plan-selection/manifest.json deleted file mode 100644 index adacaba6..00000000 --- a/plugins/pages/plan-selection/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "plan-selection", - "name": "Plan Selection", - "version": "1.0.0", - "description": "套餐选择页面插件", - "author": "Simprint Team", - "entry": "./src/index.tsx", - "slots": [], - "enabled": true -} diff --git a/plugins/pages/plan-selection/package.json b/plugins/pages/plan-selection/package.json deleted file mode 100644 index 949b7237..00000000 --- a/plugins/pages/plan-selection/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "plugin-plan-selection", - "version": "1.0.0", - "main": "src/index.tsx", - "private": true -} diff --git a/plugins/pages/plan-selection/src/api/index.ts b/plugins/pages/plan-selection/src/api/index.ts deleted file mode 100644 index 2f91cf67..00000000 --- a/plugins/pages/plan-selection/src/api/index.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * 套餐选择 API - */ -import { post, isSuccess } from '@/lib/request'; -import type { - PlanDto, - PlanFeatureDto, - PlanWithFeatures, - SubscriptionDto, - WalletResponse, - SubscribePlanRequest, - SubscribePlanResponse, - PlansResponse, - CurrentPlanResponse, - ReferralPlanSummary, -} from './index.types'; - -export * from './index.types'; - -// ============ API 端点 ============ - -export const API_ENDPOINTS = { - PLANS: 'billing/plans', - SUBSCRIPTION: 'billing/subscription', - SUBSCRIPTION_SUBSCRIBE: 'billing/subscription/subscribe', - WALLET: 'billing/wallet', - REFERRAL_PLAN_SUMMARY: 'referral/summary-for-plan', -} as const; - -// ============ API 函数 ============ - -/** - * 获取套餐列表(包含特性) - */ -export async function getPlans(couponCode?: string, billingPeriod: string = 'monthly'): Promise { - const result = await post(API_ENDPOINTS.PLANS, { - coupon_code: couponCode, - billing_period: billingPeriod, - }); - if (!isSuccess(result)) { - throw new Error(result.message || '获取套餐列表失败'); - } - return result.data!; -} - -/** - * 获取当前订阅 - */ -export async function getCurrentSubscription(): Promise { - const result = await post(API_ENDPOINTS.SUBSCRIPTION, {}); - if (!isSuccess(result)) { - throw new Error(result.message || '获取当前订阅失败'); - } - return result.data ?? null; -} - -/** - * 订阅套餐 - */ -export async function subscribePlan( - planUuid: string, - billingPeriod: string = 'monthly', - couponCode?: string, - paymentMethod?: string -): Promise { - const payload: SubscribePlanRequest = { - plan_uuid: planUuid, - billing_period: billingPeriod, - }; - if (couponCode) { - payload.coupon_code = couponCode; - } - if (paymentMethod) { - payload.payment_method = paymentMethod; - } - - const result = await post(API_ENDPOINTS.SUBSCRIPTION_SUBSCRIBE, payload); - if (!isSuccess(result)) { - throw new Error(result.message || '订阅失败'); - } - return result.data!; -} - -/** - * 获取钱包信息 - */ -export async function getWallet(): Promise { - const result = await post(API_ENDPOINTS.WALLET, {}); - if (!isSuccess(result)) { - throw new Error(result.message || '获取钱包信息失败'); - } - return result.data!; -} - -/** - * 获取套餐页推广摘要信息 - */ -export async function getReferralPlanSummary(): Promise { - const result = await post(API_ENDPOINTS.REFERRAL_PLAN_SUMMARY, {}); - if (!isSuccess(result)) { - throw new Error(result.message || '获取推广摘要失败'); - } - return result.data ?? null; -} - diff --git a/plugins/pages/plan-selection/src/api/index.types.ts b/plugins/pages/plan-selection/src/api/index.types.ts deleted file mode 100644 index 0dc4b1a4..00000000 --- a/plugins/pages/plan-selection/src/api/index.types.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * 套餐选择 API 类型定义 - */ - -// ============ 后端 DTO 类型 ============ - -export interface PlanDto { - id: number; - uuid: string; - name: string; - description: string | null; - price_per_month: number; - price_per_year: number; - currency: string; - discount_monthly: number | null; - discount_yearly: number | null; - max_environments: number; - max_team_members: number; - max_proxies: number; - max_rpa_tasks: number; - is_recommended: boolean | null; - sort_order: number | null; - status: string; - created_at: string; - updated_at: string; -} - -export interface PlanFeatureDto { - id: number; - plan_uuid: string; - feature_key: string; - feature_name: string; - feature_value: string | null; - is_included: boolean | null; - sort_order: number | null; - created_at: string; -} - -export interface PlanPriceInfo { - original_price: number | string; - plan_discount: number | string; - coupon_discount: number | string; - final_price: number | string; - total_saved: number | string; - billing_period: string; -} - -export interface PlanWithFeatures { - plan: PlanDto; - features: PlanFeatureDto[]; - calculated_price?: PlanPriceInfo | null; -} - -export interface SubscriptionDto { - id: number; - uuid: string; - workspace_uuid: string; - user_uuid: string; - plan_uuid: string; - billing_period: string; - price: number; - currency: string; - started_at: string; - expires_at: string; - next_billing_date: string | null; - auto_renew: boolean | null; - status: string; - cancelled_at: string | null; - created_at: string; - updated_at: string; -} - -export interface WalletResponse { - id: number; - user_uuid: string; - balance: number; - currency: string; - frozen_amount: number; - auto_renewal_combined: number; - created_at: string; - updated_at: string; -} - -// 注意:WalletResponse 与 billing-center 中的 WalletResponse 相同 -// 可以统一使用 billing-center 的类型定义,但为了保持模块独立性,这里也定义一份 - -// ============ 请求类型 ============ - -export interface SubscribePlanRequest { - plan_uuid: string; - billing_period: string; - coupon_code?: string; - payment_method?: string; // wallet | alipay | wechat -} - -export interface SubscribePlanResponse { - subscription_uuid: string; -} - -// ============ 响应类型 ============ - -export interface PlansResponse { - plans: PlanWithFeatures[]; -} - -export interface CurrentPlanResponse { - data: SubscriptionDto | null; -} - -// ============ 推广摘要类型 ============ - -export interface ReferralPlanSummary { - referral_value_last_30_days: string | number; - current_plan_monthly_price: string | number | null; - coverage_ratio: string | number | null; -} - diff --git a/plugins/pages/plan-selection/src/components/coupon-selector-dialog.tsx b/plugins/pages/plan-selection/src/components/coupon-selector-dialog.tsx deleted file mode 100644 index bb5f51ec..00000000 --- a/plugins/pages/plan-selection/src/components/coupon-selector-dialog.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Ticket, Check } from 'lucide-react'; -import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; -import { Button } from '@/components/ui/button'; -import { ScrollArea } from '@/components/ui/scroll-area'; -// @ts-ignore - Cross-plugin import -import { getAvailableCoupons } from '../../../billing-center/src/api'; -// @ts-ignore - Cross-plugin import -import type { UserCoupon } from '../../../billing-center/src/api/index.types'; - -interface CouponSelectorDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - selectedCouponCode?: string; - onSelect: (couponCode: string | null) => void; -} - -/** - * 优惠券选择弹窗组件 - */ -export function CouponSelectorDialog({ - open, - onOpenChange, - selectedCouponCode, - onSelect, -}: CouponSelectorDialogProps) { - const { t, i18n } = useTranslation('plans'); - const [allCoupons, setAllCoupons] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - if (open) { - loadCoupons(); - } - }, [open]); - - const loadCoupons = async () => { - try { - setLoading(true); - const data = await getAvailableCoupons(); - setAllCoupons(data || []); - } catch (error) { - console.error('获取优惠券失败:', error); - setAllCoupons([]); - } finally { - setLoading(false); - } - }; - - const formatDiscount = (coupon: UserCoupon) => { - let value: number; - if (typeof coupon.discount_value === 'string') { - value = parseFloat(coupon.discount_value); - } else if (typeof coupon.discount_value === 'number') { - value = coupon.discount_value; - } else { - value = 0; - } - - if (isNaN(value) || value == null) { - return '-$0.00'; - } - - if (coupon.discount_type === 'percentage') { - return `-${value}%`; - } else { - return `-$${value.toFixed(2)}`; - } - }; - - const formatDate = (dateStr: string) => { - const date = new Date(dateStr); - const locale = i18n.language === 'en-US' ? 'en-US' : 'zh-CN'; - return date.toLocaleDateString(locale); - }; - - const handleSelect = (coupon: UserCoupon) => { - if (selectedCouponCode === coupon.code) { - // 如果已选中,则取消选择 - onSelect(null); - } else { - onSelect(coupon.code); - } - }; - - return ( - - {loading ? ( -
- {[1, 2, 3, 4, 5].map((i) => ( -
- ))} -
- ) : allCoupons.length === 0 ? ( -
-
- -
-

{t('couponSelector.noCoupons')}

-

- {t('couponSelector.noCouponsDescription')} -

-
- ) : ( - -
- {allCoupons.map((coupon) => { - const isSelected = selectedCouponCode === coupon.code; - return ( -
handleSelect(coupon)} - > - {/* 选中标记 */} - {isSelected && ( -
-
- -
-
- )} - - {/* 背景装饰 */} -
- -
- - {/* 内容 */} -
-
-
-
- - - {coupon.name || coupon.code || t('couponSelector.unknown')} - -
- {coupon.description && ( -
- {coupon.description} -
- )} -
- {formatDiscount(coupon)} - {coupon.min_amount != null && (() => { - const minAmount = - typeof coupon.min_amount === 'string' - ? parseFloat(coupon.min_amount) - : coupon.min_amount; - return minAmount != null && !isNaN(minAmount) && minAmount > 0 ? ( - - {t('couponSelector.minAmount', { - amount: minAmount.toFixed(2), - })} - - ) : null; - })()} -
-
-
- {coupon.expires_at && ( -
- {t('couponSelector.expiresAt', { - date: formatDate(coupon.expires_at), - })} -
- )} -
-
- ); - })} -
-
- )} - - - - - - - ); -} diff --git a/plugins/pages/plan-selection/src/components/payment-dialog.tsx b/plugins/pages/plan-selection/src/components/payment-dialog.tsx deleted file mode 100644 index 8c2df73b..00000000 --- a/plugins/pages/plan-selection/src/components/payment-dialog.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Wallet, CreditCard, Smartphone, Loader2 } from 'lucide-react'; -import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; -import { Button } from '@/components/ui/button'; -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { Label } from '@/components/ui/label'; -import type { BillingPlan, PlanPriceInfo } from '../types'; -import { getWallet } from '../api'; - -export type PaymentMethod = 'wallet' | 'alipay' | 'wechat'; - -interface PaymentDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - plan: BillingPlan; - priceInfo: PlanPriceInfo; - couponCode?: string; - onPayment: (method: PaymentMethod) => void; - onRecharge: (amount: number) => void; -} - -/** - * 支付弹窗组件 - */ -export function PaymentDialog({ - open, - onOpenChange, - plan, - priceInfo, - couponCode, - onPayment, - onRecharge, -}: PaymentDialogProps) { - const { t } = useTranslation('plans'); - const [paymentMethod, setPaymentMethod] = useState('wallet'); - const [walletBalance, setWalletBalance] = useState(0); - const [loading, setLoading] = useState(false); - - // 获取钱包余额 - useEffect(() => { - if (open) { - setLoading(true); - getWallet() - .then((wallet) => { - setWalletBalance(Number(wallet.balance) || 0); - }) - .catch(() => { - setWalletBalance(0); - }) - .finally(() => { - setLoading(false); - }); - } - }, [open]); - - const amount = priceInfo.actualPrice; - const isBalanceSufficient = walletBalance >= amount; - const insufficientAmount = isBalanceSufficient ? 0 : amount - walletBalance; - - const handlePayment = () => { - if (paymentMethod === 'wallet' && !isBalanceSufficient) { - // 余额不足,显示充值弹窗 - onRecharge(insufficientAmount); - onOpenChange(false); - } else { - // 直接支付 - onPayment(paymentMethod); - onOpenChange(false); - } - }; - - return ( - -
- {/* 消费详情 */} -
-

{t('payment.orderDetails')}

-
-
- {t('payment.planName')} - {plan.name} -
-
- {t('payment.subscriptionDuration')} - {t('right.durationValue')} -
-
- {t('payment.amount')} - ${amount.toFixed(2)} -
- {couponCode && ( -
- {t('payment.coupon')} - {couponCode} -
- )} -
-
- - {/* 支付方式选择 */} -
-

{t('payment.paymentMethod')}

- setPaymentMethod(value as PaymentMethod)}> -
- - -
- -
- - -
- -
- - -
-
-
-
- - - - - -
- ); -} - diff --git a/plugins/pages/plan-selection/src/components/plan-banner.tsx b/plugins/pages/plan-selection/src/components/plan-banner.tsx deleted file mode 100644 index 6ee73e98..00000000 --- a/plugins/pages/plan-selection/src/components/plan-banner.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Info } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import type { CurrentPlan, BillingPlan, ReferralPlanSummaryView } from '../types'; - -interface PlanBannerProps { - currentPlan: CurrentPlan | null; - plans: BillingPlan[]; - selectedPlanId?: string | null; - referralSummary?: ReferralPlanSummaryView | null; -} - -/** - * 顶部状态横幅组件 - */ -export function PlanBanner({ - currentPlan, - plans, - selectedPlanId, - referralSummary, -}: PlanBannerProps) { - const { t } = useTranslation('plans'); - - if (!currentPlan) return null; - - // 获取选中套餐 - const selectedPlan = selectedPlanId ? plans.find((p) => p.id === selectedPlanId) : null; - - // 如果有选中套餐,使用选中套餐的信息;否则使用当前套餐的信息 - const displayPlanName = selectedPlan - ? selectedPlan.name - : plans.find((p) => p.id === currentPlan.id)?.name || '免费'; - const displayMaxEnvironments = selectedPlan - ? selectedPlan.environmentsLimit || 0 - : currentPlan.maxEnvironments || 0; - - // 实际使用的环境数量始终使用当前的实际使用量 - const usedEnvironments = currentPlan.environmentsUsed || 0; - - return ( -
-
- -
- {t('banner.current', { - plan: displayPlanName, - limit: displayMaxEnvironments, - })} - {'; '} - {t('banner.usage', { - used: usedEnvironments, - total: displayMaxEnvironments, - })} -
-
- - {referralSummary && referralSummary.referralValueLast30Days > 0 && ( -
- {referralSummary.currentPlanMonthlyPrice && referralSummary.coverageRatio != null - ? t('banner.referralCoverage', { - value: referralSummary.referralValueLast30Days.toFixed(2), - coverage: Math.min(referralSummary.coverageRatio, 1) * 100, - }) - : t('banner.referralValueOnly', { - value: referralSummary.referralValueLast30Days.toFixed(2), - })} -
- )} -
- ); -} diff --git a/plugins/pages/plan-selection/src/components/plan-card-skeleton.tsx b/plugins/pages/plan-selection/src/components/plan-card-skeleton.tsx deleted file mode 100644 index 56f03e3e..00000000 --- a/plugins/pages/plan-selection/src/components/plan-card-skeleton.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -export function PlanCardSkeleton() { - return ( -
- {/* 徽章位置 */} -
- -
-
- {/* 套餐名称 */} - - {/* 价格 */} -
- - -
- {/* 描述 */} - - -
-
- ); -} diff --git a/plugins/pages/plan-selection/src/components/plan-card.tsx b/plugins/pages/plan-selection/src/components/plan-card.tsx deleted file mode 100644 index 7cdeed45..00000000 --- a/plugins/pages/plan-selection/src/components/plan-card.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import type { BillingPlan } from '../types'; - -interface PlanCardProps { - plan: BillingPlan; - isSelected: boolean; - onSelect: (planId: string) => void; -} - -/** - * 套餐选择卡片组件 - */ -export function PlanCard({ plan, isSelected, onSelect }: PlanCardProps) { - const { t } = useTranslation('plans'); - - return ( -
onSelect(plan.id)} - className={`relative border-2 rounded-lg p-4 cursor-pointer transition-all ${isSelected ? 'border-primary shadow-md' : 'border-border hover:border-primary/50' - } ${plan.popular ? 'ring-1 ring-primary/20' : ''}`} - > - {plan.popular && ( -
- - {plan.badge || t('badge.popular')} - -
- )} -
-

{plan.name}

-

{plan.description}

-
-
- ); -} diff --git a/plugins/pages/plan-selection/src/components/plan-details-panel.tsx b/plugins/pages/plan-selection/src/components/plan-details-panel.tsx deleted file mode 100644 index d4ebb10d..00000000 --- a/plugins/pages/plan-selection/src/components/plan-details-panel.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { CreditCard, Ticket, Edit2 } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/animate-ui/components/radix/checkbox'; -import type { BillingPlan, CurrentPlan, PlanPriceInfo } from '../types'; -import type { PaymentMethod } from './payment-dialog'; - -interface PlanDetailsPanelProps { - plan: BillingPlan; - priceInfo: PlanPriceInfo; - currentPlan: CurrentPlan | null; - agreed: boolean; - subscribing: boolean; - couponCode?: string; - onAgreedChange: (agreed: boolean) => void; - onPayment: () => void; - onOpenCouponSelector: () => void; -} - -/** - * 右侧套餐详情面板组件 - */ -export function PlanDetailsPanel({ - plan, - priceInfo, - currentPlan, - agreed, - subscribing, - couponCode, - onAgreedChange, - onPayment, - onOpenCouponSelector, -}: PlanDetailsPanelProps) { - const { t } = useTranslation('plans'); - - return ( -
- {/* 套餐详情部分 */} -
-

{t('right.details')}

-
- {/* 环境数量 */} -
-
- {t('right.environments')} -
-
{plan.environmentsLimit}
-
- - {/* 订阅时长 */} -
-
- {t('right.subscriptionDuration')} -
-
{t('right.durationValue')}
-
- - {/* 到期时间 */} - {currentPlan?.expiresAt && ( -
-
- {t('right.expiresAt')} -
-
- {new Date(currentPlan.expiresAt).toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - })} -
-
- )} -
-
- - {/* 分割线 */} -
- - {/* 价格信息部分 */} -
- {/* 首购折扣 */} - {priceInfo.discountPercent && ( -
-
- {t('right.firstPurchaseDiscount')} -
-
- -{priceInfo.discountPercent}% -
-
- )} - - {/* 套餐原价 */} -
-
- {t('right.originalPrice')} -
-
- - ${priceInfo.originalPrice.toFixed(2)} - - /{t('price.unit')} -
-
- - {/* 实际价格(突出显示) */} -
-
{t('right.actualPrice')}
-
- - ${priceInfo.actualPrice.toFixed(2)} - - /{t('price.unit')} -
-
- - {/* 节省金额 */} - {priceInfo.savedAmount > 0 && ( -
- {t('right.savedAmount', { amount: priceInfo.savedAmount.toFixed(2) })} -
- )} - - {/* 优惠券信息 */} -
- {couponCode ? ( -
-
- -
- {t('right.couponApplied', { code: couponCode })} -
-
- -
- ) : ( - - )} -
-
- - {/* 支付按钮 */} -
- - -
-
- ); -} diff --git a/plugins/pages/plan-selection/src/components/plan-features-list.tsx b/plugins/pages/plan-selection/src/components/plan-features-list.tsx deleted file mode 100644 index 68cfdf34..00000000 --- a/plugins/pages/plan-selection/src/components/plan-features-list.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import type { BillingPlan } from '../types'; -import { getFeatureIcon } from '../utils/plan-price'; - -interface PlanFeaturesListProps { - plan: BillingPlan; -} - -/** - * 详细功能列表组件 - */ -export function PlanFeaturesList({ plan }: PlanFeaturesListProps) { - const { t } = useTranslation('plans'); - - return ( -
-

{t('left.features')}

-
- {plan.features.map((feature) => { - const Icon = getFeatureIcon(feature.id); - return ( -
- -
- {feature.name} - {feature.description && ( -

{feature.description}

- )} -
-
- ); - })} -
-
- ); -} diff --git a/plugins/pages/plan-selection/src/components/plan-quota-bar.tsx b/plugins/pages/plan-selection/src/components/plan-quota-bar.tsx deleted file mode 100644 index b6b6a186..00000000 --- a/plugins/pages/plan-selection/src/components/plan-quota-bar.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useMemo } from 'react'; -import type { BillingPlan } from '../types'; - -interface PlanQuotaBarProps { - plans: BillingPlan[]; - selectedPlan: BillingPlan | undefined; - selectedPlanId: string | null; - onSelect: (planId: string) => void; -} - -/** - * 环境配额显示条组件(参考创建环境的分段滑块设计) - */ -export function PlanQuotaBar({ plans, selectedPlan, selectedPlanId, onSelect }: PlanQuotaBarProps) { - const { t } = useTranslation('plans'); - - const maxQuota = plans.length > 0 ? Math.max(...plans.map((p) => p.environmentsLimit)) : 100; - const environmentsLimit = selectedPlan?.environmentsLimit || 0; - - // 将套餐按配额排序 - const sortedPlans = useMemo(() => { - return [...plans].sort((a, b) => a.environmentsLimit - b.environmentsLimit); - }, [plans]); - - // 计算每个套餐分段的位置和宽度(基于增量的智能缩放) - const planSegments = useMemo(() => { - if (sortedPlans.length === 0) return []; - - // 计算每个分段的增量(从上一个套餐到当前套餐的差值) - const increments = sortedPlans.map((plan, index) => { - const prevValue = index === 0 ? 0 : sortedPlans[index - 1].environmentsLimit; - return { - plan, - increment: plan.environmentsLimit - prevValue, - }; - }); - - // 使用幂函数缩放增量(value^0.4,比平方根更激进,比对数更温和) - // 这样可以让小增量占据更多空间,大增量占据更少空间 - const scaleIncrement = (increment: number, maxIncrement: number): number => { - if (increment <= 0) return 0; - if (maxIncrement <= 0) return 0; - // 使用幂函数:increment^0.4 / maxIncrement^0.4 - const power = 0.3; - return (Math.pow(increment, power) / Math.pow(maxIncrement, power)) * 100; - }; - - // 找到最大增量 - const maxIncrement = Math.max(...increments.map((inc) => inc.increment)); - - // 计算每个增量的缩放宽度 - const scaledIncrements = increments.map((inc) => ({ - plan: inc.plan, - scaledWidth: scaleIncrement(inc.increment, maxIncrement), - })); - - // 计算总宽度,用于归一化到100% - const totalScaledWidth = scaledIncrements.reduce((sum, inc) => sum + inc.scaledWidth, 0); - - // 计算每个分段的位置和宽度 - let currentPosition = 0; - return scaledIncrements.map((item) => { - const widthPercent = (item.scaledWidth / totalScaledWidth) * 100; - const startPercent = currentPosition; - const endPercent = currentPosition + widthPercent; - currentPosition = endPercent; - - return { - plan: item.plan, - startPercent, - endPercent, - widthPercent, - isSelected: item.plan.id === selectedPlanId, - }; - }); - }, [sortedPlans, selectedPlanId]); - - // 计算当前选中套餐在分段中的位置 - const currentSegmentIndex = useMemo(() => { - if (!selectedPlan) return -1; - return planSegments.findIndex((seg) => seg.plan.id === selectedPlanId); - }, [selectedPlan, selectedPlanId, planSegments]); - - // 计算分段渐变颜色(根据位置从浅到深) - const getSegmentGradient = (startPercent: number, endPercent: number): string => { - // 使用主题 primary 颜色,从 0.5 到 1.0 透明度 - const startOpacity = 0.5 + (startPercent / 100) * 0.5; - const endOpacity = 0.5 + (endPercent / 100) * 0.5; - const startColor = `rgba(37, 99, 235, ${startOpacity})`; // primary color #2563eb - const endColor = `rgba(37, 99, 235, ${endOpacity})`; - return `linear-gradient(to right, ${startColor}, ${endColor})`; - }; - - // 计算灰色渐变背景(根据位置从浅到深) - const getGrayGradient = (startPercent: number, endPercent: number): string => { - const startLightness = 245 - (startPercent / 100) * 20; // 245 -> 225 - const endLightness = 245 - (endPercent / 100) * 20; - const startColor = `rgb(${startLightness}, ${startLightness + 1}, ${startLightness + 3})`; - const endColor = `rgb(${endLightness}, ${endLightness + 1}, ${endLightness + 3})`; - return `linear-gradient(to right, ${startColor}, ${endColor})`; - }; - - return ( -
- {/* 标题和配额 */} -
- {t('left.environmentQuota')} - {selectedPlan && ( - - {environmentsLimit} {t('left.environmentsUnit')} - - )} -
- - {/* 分段滑块样式(水平,参考创建环境的分段滑块) */} -
- {planSegments.map((segment, index) => { - const isBeforeCurrent = index < currentSegmentIndex; - const isCurrentSegment = index === currentSegmentIndex; - const isAfterCurrent = index > currentSegmentIndex; - - // 计算当前段的高亮比例(0-1) - // 如果完全在选中套餐之前,高亮比例为 1 - // 如果是当前段,高亮比例为 1(因为选中套餐就是当前段) - // 如果在选中套餐之后,高亮比例为 0 - const highlightProgress = isBeforeCurrent ? 1 : isCurrentSegment ? 1 : 0; - - return ( - - ); - })} -
- -
- ); -} diff --git a/plugins/pages/plan-selection/src/components/plan-selection-content.tsx b/plugins/pages/plan-selection/src/components/plan-selection-content.tsx deleted file mode 100644 index 233888c8..00000000 --- a/plugins/pages/plan-selection/src/components/plan-selection-content.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import type { BillingPlan } from '../types'; -import { PlanCard } from './plan-card'; -import { PlanQuotaBar } from './plan-quota-bar'; -import { PlanFeaturesList } from './plan-features-list'; - -interface PlanSelectionContentProps { - plans: BillingPlan[]; - selectedPlanId: string | null; - selectedPlan: BillingPlan | undefined; - onSelect: (planId: string) => void; -} - -/** - * 左侧套餐选择内容组件 - */ -export function PlanSelectionContent({ - plans, - selectedPlanId, - selectedPlan, - onSelect, -}: PlanSelectionContentProps) { - const { t } = useTranslation('plans'); - - return ( -
- {/* 套餐选择卡片 */} -
-

{t('left.selectPlan')}

-
- {plans.map((plan) => ( - - ))} -
-
- - {/* 环境配额显示 */} - - - {/* 分割线 */} -
- - {/* 详细功能列表 */} - {selectedPlan && } -
- ); -} diff --git a/plugins/pages/plan-selection/src/components/plan-selection-skeleton.tsx b/plugins/pages/plan-selection/src/components/plan-selection-skeleton.tsx deleted file mode 100644 index 7a958152..00000000 --- a/plugins/pages/plan-selection/src/components/plan-selection-skeleton.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; -import { PlanCardSkeleton } from './plan-card-skeleton'; - -export function PlanSelectionSkeleton() { - return ( -
-
-
- {/* 顶部状态横幅 */} - - -
- {/* 左侧内容 - 2/3 宽度 */} -
- {/* 套餐选择卡片 */} -
- -
- {Array.from({ length: 3 }).map((_, index) => ( - - ))} -
-
- - {/* 环境配额显示 */} -
-
- - -
- - -
- - {/* 分割线 */} - - - {/* 详细功能列表 */} -
- -
- {Array.from({ length: 6 }).map((_, index) => ( -
- -
- - -
-
- ))} -
-
-
- - {/* 右侧内容 - 1/3 宽度 */} -
- {/* 套餐详情部分 */} -
- -
- {Array.from({ length: 3 }).map((_, index) => ( -
- - -
- ))} -
-
- - {/* 分割线 */} - - - {/* 价格信息部分 */} -
- {Array.from({ length: 4 }).map((_, index) => ( -
- - -
- ))} -
- - {/* 支付按钮 */} -
-
- - -
- -
-
-
-
-
-
- ); -} diff --git a/plugins/pages/plan-selection/src/components/recharge-dialog.tsx b/plugins/pages/plan-selection/src/components/recharge-dialog.tsx deleted file mode 100644 index 12103488..00000000 --- a/plugins/pages/plan-selection/src/components/recharge-dialog.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { CreditCard, Smartphone, Wallet, Loader2 } from 'lucide-react'; -import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; -import { Button } from '@/components/ui/button'; -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { Label } from '@/components/ui/label'; -import { toast } from 'sonner'; - -export type RechargeMethod = 'alipay' | 'wechat'; - -interface RechargeDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - amount: number; - onSuccess: () => void; -} - -/** - * 充值弹窗组件 - */ -export function RechargeDialog({ open, onOpenChange, amount, onSuccess }: RechargeDialogProps) { - const { t } = useTranslation('plans'); - const [rechargeMethod, setRechargeMethod] = useState('alipay'); - const [processing, setProcessing] = useState(false); - - const handleRecharge = async () => { - setProcessing(true); - try { - // TODO: 对接支付接口 - // 当前为开发阶段,直接模拟充值成功 - // 实际实现时需要: - // 1. 调用后端创建充值订单接口 - // 2. 根据支付方式跳转到对应的支付页面(支付宝/微信) - // 3. 处理支付回调 - // 4. 更新钱包余额 - - // 模拟支付成功 - await new Promise((resolve) => setTimeout(resolve, 1000)); - - toast.success(t('recharge.success', { amount: amount.toFixed(2) })); - onSuccess(); - onOpenChange(false); - } catch (error) { - toast.error(t('recharge.error')); - } finally { - setProcessing(false); - } - }; - - return ( - -
- {/* 充值金额 */} -
-

{t('recharge.amount')}

-
-
-
${amount.toFixed(2)}
-
{t('recharge.amountDescription')}
-
-
-
- - {/* 支付方式选择 */} -
-

{t('recharge.paymentMethod')}

- setRechargeMethod(value as RechargeMethod)} - > -
- - -
- -
- - -
-
-
- - {/* 描述信息 */} -
-
- -
- {t('recharge.info')} -
-
-
-
- - - - - -
- ); -} - diff --git a/plugins/pages/plan-selection/src/constants.ts b/plugins/pages/plan-selection/src/constants.ts deleted file mode 100644 index ca83552b..00000000 --- a/plugins/pages/plan-selection/src/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * 默认计费周期 - */ -export const DEFAULT_BILLING_PERIOD = 'monthly' as const; diff --git a/plugins/pages/plan-selection/src/hooks/use-current-plan.ts b/plugins/pages/plan-selection/src/hooks/use-current-plan.ts deleted file mode 100644 index 44a97c2f..00000000 --- a/plugins/pages/plan-selection/src/hooks/use-current-plan.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { post, isSuccess } from '@/lib/request'; -import type { CurrentPlan } from '../types'; -import { getCurrentSubscription } from '../api'; - -interface UseCurrentPlanReturn { - currentPlan: CurrentPlan | null; - loading: boolean; - refresh: () => Promise; -} - -interface QuotaResponse { - workspace_uuid: string; - max_environments: number; - used_environments: number; - max_team_members: number; - used_team_members: number; - max_proxies: number; - used_proxies: number; - max_rpa_tasks: number; - used_rpa_tasks: number; - created_at: string; - updated_at: string; -} - -/** - * 获取工作空间配额 - */ -async function getQuota(): Promise { - try { - const result = await post('billing/quota', {}); - if (!isSuccess(result)) { - return null; - } - return result.data ?? null; - } catch { - return null; - } -} - -/** - * 获取当前套餐的 Hook - */ -export function useCurrentPlan(): UseCurrentPlanReturn { - const [currentPlan, setCurrentPlan] = useState(null); - const [loading, setLoading] = useState(false); - - const fetchCurrentPlan = useCallback(async () => { - setLoading(true); - try { - const [subscription, quota] = await Promise.all([ - getCurrentSubscription(), - getQuota(), - ]); - - if (subscription) { - setCurrentPlan({ - id: subscription.plan_uuid, - expiresAt: subscription.expires_at, - environmentsUsed: quota?.used_environments ?? 0, - maxEnvironments: quota?.max_environments ?? 0, - }); - } else if (quota) { - // 如果没有订阅,使用配额中的默认值 - setCurrentPlan({ - id: 'free', - expiresAt: undefined, - environmentsUsed: quota.used_environments, - maxEnvironments: quota.max_environments, - }); - } else { - setCurrentPlan(null); - } - } catch { - // 忽略错误,设置为 null - setCurrentPlan(null); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void fetchCurrentPlan(); - }, [fetchCurrentPlan]); - - return { - currentPlan, - loading, - refresh: fetchCurrentPlan, - }; -} diff --git a/plugins/pages/plan-selection/src/hooks/use-plan-selection.ts b/plugins/pages/plan-selection/src/hooks/use-plan-selection.ts deleted file mode 100644 index 1f20bd4b..00000000 --- a/plugins/pages/plan-selection/src/hooks/use-plan-selection.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { BillingPlan } from '../types'; - -interface UsePlanSelectionReturn { - selectedPlanId: string | null; - setSelectedPlanId: (id: string | null) => void; - selectedPlan: BillingPlan | undefined; - initializeSelection: (plans: BillingPlan[], currentPlanId?: string) => void; -} - -/** - * 套餐选择状态管理 Hook - */ -export function usePlanSelection(plans: BillingPlan[]): UsePlanSelectionReturn { - const [selectedPlanId, setSelectedPlanId] = useState(null); - - const initializeSelection = useCallback( - (plansToSelect: BillingPlan[], currentPlanId?: string) => { - if (currentPlanId && plansToSelect.some((p) => p.id === currentPlanId)) { - // 如果 currentPlanId 存在且在套餐列表中,选择它 - setSelectedPlanId(currentPlanId); - } else if (plansToSelect.length > 0) { - // 否则默认选择第一个套餐 - setSelectedPlanId(plansToSelect[0].id); - } - }, - [] - ); - - const selectedPlan = plans.find((p) => p.id === selectedPlanId); - - return { - selectedPlanId, - setSelectedPlanId, - selectedPlan, - initializeSelection, - }; -} diff --git a/plugins/pages/plan-selection/src/hooks/use-plans.ts b/plugins/pages/plan-selection/src/hooks/use-plans.ts deleted file mode 100644 index 1a6b2044..00000000 --- a/plugins/pages/plan-selection/src/hooks/use-plans.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import type { BillingPlan } from '../types'; -import { getPlans } from '../api'; -import { transformPlanWithFeatures } from '../utils/plan-transform'; - -interface UsePlansReturn { - plans: BillingPlan[]; - currentPlanId?: string; - loading: boolean; - error: string | null; - refresh: () => Promise; -} - -interface UsePlansParams { - couponCode?: string; - billingPeriod?: string; -} - -/** - * 获取套餐列表的 Hook - */ -export function usePlans(params?: UsePlansParams): UsePlansReturn { - const [plans, setPlans] = useState([]); - const [currentPlanId, setCurrentPlanId] = useState(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchPlans = useCallback(async () => { - setLoading(true); - setError(null); - try { - const result = await getPlans(params?.couponCode, params?.billingPeriod || 'monthly'); - const transformedPlans = result.plans.map(transformPlanWithFeatures); - setPlans(transformedPlans); - // TODO: 如果后端返回 currentPlanId,需要从响应中提取 - setCurrentPlanId(undefined); - } catch (e) { - setError(e instanceof Error ? e.message : '获取套餐列表失败'); - } finally { - setLoading(false); - } - }, [params?.couponCode, params?.billingPeriod]); - - useEffect(() => { - void fetchPlans(); - }, [fetchPlans]); - - return { - plans, - currentPlanId, - loading, - error, - refresh: fetchPlans, - }; -} diff --git a/plugins/pages/plan-selection/src/hooks/use-subscribe.ts b/plugins/pages/plan-selection/src/hooks/use-subscribe.ts deleted file mode 100644 index e721b0d2..00000000 --- a/plugins/pages/plan-selection/src/hooks/use-subscribe.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { toast } from 'sonner'; -import { subscribePlan } from '../api'; -import { DEFAULT_BILLING_PERIOD } from '../constants'; - -interface UseSubscribeReturn { - subscribing: boolean; - subscribe: (planId: string, paymentMethod?: string) => Promise; -} - -/** - * 订阅操作 Hook - */ -export function useSubscribe(couponCode?: string, onSuccess?: () => void): UseSubscribeReturn { - const { t } = useTranslation('plans'); - const [subscribing, setSubscribing] = useState(false); - - const subscribe = useCallback( - async (planId: string, paymentMethod?: string) => { - setSubscribing(true); - try { - await subscribePlan(planId, DEFAULT_BILLING_PERIOD, couponCode, paymentMethod); - toast.success(t('subscribe.success', { plan: planId })); - onSuccess?.(); - } catch (e) { - toast.error(e instanceof Error ? e.message : t('subscribe.error')); - throw e; - } finally { - setSubscribing(false); - } - }, - [t, couponCode, onSuccess] - ); - - return { - subscribing, - subscribe, - }; -} diff --git a/plugins/pages/plan-selection/src/i18n/resources.ts b/plugins/pages/plan-selection/src/i18n/resources.ts deleted file mode 100644 index e810cd51..00000000 --- a/plugins/pages/plan-selection/src/i18n/resources.ts +++ /dev/null @@ -1,224 +0,0 @@ -export const planResources = { - 'zh-CN': { - title: '套餐选择', - subtitle: '选择适合您的套餐计划', - loading: '加载中...', - error: '加载失败:{{error}}', - billingPeriod: { - monthly: '月付', - yearly: '年付', - save: '节省', - }, - price: { - unit: '月', - yearlyTotal: '年付 ${{amount}}', - }, - discount: { - save: '节省 {{percent}}%', - }, - badge: { - popular: '最受欢迎', - }, - left: { - selectPlan: '选择套餐', - environmentUsage: '环境使用情况', - environmentUsageDesc: '已使用 {{used}} 个环境,剩余 {{remaining}} 个可用(共 {{total}} 个)', - environmentQuota: '环境配额', - environmentsUnit: '个环境', - environmentQuotaDesc: '该套餐提供 {{total}} 个环境配额', - selectPlanToViewQuota: '选择套餐查看环境配额', - features: '套餐功能', - }, - banner: { - current: '当前:{{plan}}({{limit}}环境)', - usage: '用量:{{used}}/{{total}}', - selecting: '选择:{{plan}}({{limit}}环境)', - }, - right: { - details: '套餐详情', - environments: '环境配额', - environmentsUnit: '个环境', - subscriptionDuration: '订阅时长', - durationValue: '30天', - originalPrice: '套餐原价', - actualPrice: '实际价格', - firstPurchaseDiscount: '首购折扣', - savedAmount: '节省${{amount}}/月', - expiresAt: '到期时间', - couponApplied: '优惠券:{{code}}', - useCoupon: '使用优惠券', - changeCoupon: '更换优惠券', - agreement: { - text: '同意我们的', - link: '用户协议', - }, - }, - couponSelector: { - title: '选择优惠券', - description: '请选择要使用的优惠券', - searchPlaceholder: '搜索优惠券代码或名称', - noCoupons: '暂无可用优惠券', - noCouponsDescription: '您当前没有可用的优惠券', - noMatch: '未找到匹配的优惠券', - unknown: '未知优惠券', - minAmount: '最低消费 ${{amount}}', - expiresAt: '有效期至:{{date}}', - cancel: '取消', - confirm: '确认', - }, - subscribe: { - button: '立即支付', - current: '当前套餐', - processing: '处理中...', - success: '成功订阅 {{plan}} 套餐', - error: '订阅失败,请重试', - agreementRequired: '请先同意用户协议', - }, - payment: { - title: '支付订单', - description: '请选择支付方式完成订单', - orderDetails: '订单详情', - planName: '套餐名称', - subscriptionDuration: '订阅时长', - amount: '支付金额', - coupon: '优惠券', - paymentMethod: '支付方式', - wallet: '钱包支付', - alipay: '支付宝', - wechat: '微信支付', - balance: '余额', - loading: '加载中...', - insufficientBalance: '余额不足,缺少 ${{amount}}', - cancel: '取消', - confirm: '确认支付', - recharge: '立即充值', - success: '{{method}} 支付成功', - }, - recharge: { - title: '充值', - description: '请选择支付方式完成充值', - amount: '充值金额', - amountDescription: '充值后可用于订阅套餐和消费', - paymentMethod: '支付方式', - alipay: '支付宝', - wechat: '微信支付', - info: '充值成功后,余额将立即到账,可用于订阅套餐和消费。', - cancel: '取消', - confirm: '确认充值', - processing: '处理中...', - success: '充值成功 ${{amount}}', - error: '充值失败,请重试', - }, - }, - 'en-US': { - title: 'Plans', - subtitle: 'Choose a plan that fits you', - loading: 'Loading...', - error: 'Failed to load: {{error}}', - billingPeriod: { - monthly: 'Monthly', - yearly: 'Yearly', - save: 'Save', - }, - price: { - unit: 'month', - yearlyTotal: '${{amount}}/year', - }, - discount: { - save: 'Save {{percent}}%', - }, - badge: { - popular: 'Most Popular', - }, - left: { - selectPlan: 'Select Plan', - environmentUsage: 'Environment Usage', - environmentUsageDesc: '{{used}} environments used, {{remaining}} remaining ({{total}} total)', - environmentQuota: 'Environment Quota', - environmentsUnit: 'environments', - environmentQuotaDesc: 'This plan provides {{total}} environment quota', - selectPlanToViewQuota: 'Select a plan to view quota', - features: 'Plan Features', - }, - banner: { - current: 'Current:{{plan}}({{limit}} environments)', - usage: 'Usage:{{used}}/{{total}}', - selecting: 'Selecting:{{plan}}({{limit}} environments)', - }, - right: { - details: 'Plan Details', - environments: 'Environment Quota', - environmentsUnit: 'environments', - subscriptionDuration: 'Subscription Duration', - durationValue: '30 days', - originalPrice: 'Original Price', - actualPrice: 'Actual Price', - firstPurchaseDiscount: 'First Purchase Discount', - savedAmount: 'Save ${{amount}}/month', - expiresAt: 'Expires At', - couponApplied: 'Coupon: {{code}}', - useCoupon: 'Use Coupon', - changeCoupon: 'Change Coupon', - agreement: { - text: 'Agree to our', - link: 'User Agreement', - }, - }, - couponSelector: { - title: 'Select Coupon', - description: 'Please select a coupon to use', - searchPlaceholder: 'Search coupon code or name', - noCoupons: 'No Available Coupons', - noCouponsDescription: 'You currently have no available coupons', - noMatch: 'No matching coupons found', - unknown: 'Unknown Coupon', - minAmount: 'Minimum amount ${{amount}}', - expiresAt: 'Expires: {{date}}', - cancel: 'Cancel', - confirm: 'Confirm', - }, - subscribe: { - button: 'Pay Now', - current: 'Current Plan', - processing: 'Processing...', - success: 'Successfully subscribed to {{plan}} plan', - error: 'Subscription failed, please try again', - agreementRequired: 'Please agree to the User Agreement first', - }, - payment: { - title: 'Payment', - description: 'Please select a payment method to complete the order', - orderDetails: 'Order Details', - planName: 'Plan Name', - subscriptionDuration: 'Subscription Duration', - amount: 'Amount', - coupon: 'Coupon', - paymentMethod: 'Payment Method', - wallet: 'Wallet', - alipay: 'Alipay', - wechat: 'WeChat Pay', - balance: 'Balance', - loading: 'Loading...', - insufficientBalance: 'Insufficient balance, missing ${{amount}}', - cancel: 'Cancel', - confirm: 'Confirm Payment', - recharge: 'Recharge Now', - success: '{{method}} payment successful', - }, - recharge: { - title: 'Recharge', - description: 'Please select a payment method to complete the recharge', - amount: 'Recharge Amount', - amountDescription: 'After recharge, it can be used for subscription and consumption', - paymentMethod: 'Payment Method', - alipay: 'Alipay', - wechat: 'WeChat Pay', - info: 'After successful recharge, the balance will be credited immediately and can be used for subscription and consumption.', - cancel: 'Cancel', - confirm: 'Confirm Recharge', - processing: 'Processing...', - success: 'Recharge successful ${{amount}}', - error: 'Recharge failed, please try again', - }, - }, -} as const; diff --git a/plugins/pages/plan-selection/src/index.tsx b/plugins/pages/plan-selection/src/index.tsx deleted file mode 100644 index cd9de68b..00000000 --- a/plugins/pages/plan-selection/src/index.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import { extensionRegistry } from '@slotkitjs/core'; -import { useTranslation } from 'react-i18next'; -import { useSearchParams } from 'react-router'; -import { toast } from 'sonner'; -import { planResources } from './i18n/resources'; -import { useState, useEffect, useMemo } from 'react'; -import { PlanSelectionSkeleton } from './components/plan-selection-skeleton'; -import { PlanBanner } from './components/plan-banner'; -import { PlanSelectionContent } from './components/plan-selection-content'; -import { PlanDetailsPanel } from './components/plan-details-panel'; -import { PaymentDialog, type PaymentMethod } from './components/payment-dialog'; -import { RechargeDialog } from './components/recharge-dialog'; -import { CouponSelectorDialog } from './components/coupon-selector-dialog'; -import { usePlans } from './hooks/use-plans'; -import { useCurrentPlan } from './hooks/use-current-plan'; -import { usePlanSelection } from './hooks/use-plan-selection'; -import { useSubscribe } from './hooks/use-subscribe'; -import { getPlanPriceInfo } from './utils/plan-price'; -import { getReferralPlanSummary } from './api'; -import type { ReferralPlanSummaryView } from './types'; - -const PlanSelectionPage: React.FC = () => { - const { t } = useTranslation('plans'); - const [searchParams, setSearchParams] = useSearchParams(); - const [agreed, setAgreed] = useState(false); - const [paymentDialogOpen, setPaymentDialogOpen] = useState(false); - const [rechargeDialogOpen, setRechargeDialogOpen] = useState(false); - const [rechargeAmount, setRechargeAmount] = useState(0); - const [couponSelectorOpen, setCouponSelectorOpen] = useState(false); - const [referralSummary, setReferralSummary] = useState(null); - - // 从路由参数读取优惠券代码 - const couponCode = searchParams.get('coupon') || undefined; - - // 处理优惠券选择 - const handleCouponSelect = (code: string | null) => { - if (code) { - setSearchParams({ coupon: code }, { replace: true }); - } else { - // 移除优惠券参数 - const newParams = new URLSearchParams(searchParams); - newParams.delete('coupon'); - setSearchParams(newParams, { replace: true }); - } - }; - - // 数据获取(传入优惠券代码,优惠券变化时自动重新加载) - const { plans, currentPlanId: plansCurrentPlanId, loading, error } = usePlans({ - couponCode, - billingPeriod: 'monthly', // 默认使用月度计费 - }); - const { currentPlan, refresh: refreshCurrentPlan } = useCurrentPlan(); - - // 套餐选择 - const { selectedPlanId, setSelectedPlanId, selectedPlan, initializeSelection } = - usePlanSelection(plans); - - // 订阅操作(传入优惠券代码) - const { subscribing, subscribe } = useSubscribe(couponCode, async () => { - // 订阅成功后,延迟一下再刷新,确保后端数据已更新 - await new Promise((resolve) => setTimeout(resolve, 500)); - await refreshCurrentPlan(); - }); - - // 初始化选择 - useEffect(() => { - if (plans.length > 0 && !selectedPlanId) { - // 优先使用 plans API 返回的 currentPlanId,否则使用 currentPlan?.id - // 如果 currentPlanId 不在 plans 列表中或为空,则选择第一个套餐 - const currentPlanId = plansCurrentPlanId || currentPlan?.id; - const planExists = - currentPlanId && - typeof currentPlanId === 'string' && - currentPlanId.trim() !== '' && - plans.some((p) => p.id === currentPlanId); - initializeSelection(plans, planExists ? currentPlanId : undefined); - } - }, [plans, selectedPlanId, plansCurrentPlanId, currentPlan?.id, initializeSelection]); - - // 加载推广摘要(与当前订阅绑定) - useEffect(() => { - const loadReferralSummary = async () => { - try { - const summary = await getReferralPlanSummary(); - if (!summary) { - setReferralSummary(null); - return; - } - const referralValue = - typeof summary.referral_value_last_30_days === 'string' - ? parseFloat(summary.referral_value_last_30_days) - : summary.referral_value_last_30_days; - const planPriceRaw = summary.current_plan_monthly_price; - const planPrice = - planPriceRaw == null - ? null - : typeof planPriceRaw === 'string' - ? parseFloat(planPriceRaw) - : planPriceRaw; - const coverageRaw = summary.coverage_ratio; - const coverage = - coverageRaw == null - ? null - : typeof coverageRaw === 'string' - ? parseFloat(coverageRaw) - : coverageRaw; - - setReferralSummary({ - referralValueLast30Days: Number.isFinite(referralValue) ? referralValue : 0, - currentPlanMonthlyPrice: planPrice, - coverageRatio: coverage, - }); - } catch { - // 推广摘要失败不影响套餐页面主流程 - setReferralSummary(null); - } - }; - - void loadReferralSummary(); - }, []); - - // 价格计算 - const priceInfo = useMemo(() => { - if (!selectedPlan) return null; - return getPlanPriceInfo(selectedPlan); - }, [selectedPlan]); - - // 处理支付弹窗打开 - const handlePayment = () => { - if (!selectedPlanId) return; - if (!agreed) { - toast.warning(t('subscribe.agreementRequired')); - return; - } - setPaymentDialogOpen(true); - }; - - // 处理支付 - const handlePaymentConfirm = async (method: PaymentMethod) => { - if (!selectedPlanId) return; - - try { - if (method === 'wallet') { - // 钱包支付,直接订阅 - await subscribe(selectedPlanId, 'wallet'); - } else { - // TODO: 对接支付接口 - // 当前为开发阶段,直接模拟支付成功 - // 实际实现时需要: - // 1. 调用后端创建支付订单接口 - // 2. 根据支付方式跳转到对应的支付页面(支付宝/微信) - // 3. 处理支付回调 - // 4. 支付成功后调用订阅接口 - - // 模拟支付成功 - await new Promise((resolve) => setTimeout(resolve, 1000)); - toast.success(t('payment.success', { method: t(`payment.${method}`) })); - // 传递支付方式参数,后端将跳过钱包余额检查 - await subscribe(selectedPlanId, method); - } - } catch (error) { - // 错误已在 subscribe Hook 中处理 - } - }; - - // 处理充值 - const handleRecharge = (amount: number) => { - setRechargeAmount(amount); - setRechargeDialogOpen(true); - }; - - // 充值成功后刷新钱包余额(支付弹窗会重新获取) - const handleRechargeSuccess = () => { - // 充值成功后,重新打开支付弹窗 - setPaymentDialogOpen(true); - }; - - return ( -
-
- {loading && } - {error && ( -
- {t('error', { error })} -
- )} - {!loading && !error && ( -
- {/* 顶部状态横幅 */} - - -
- {/* 左侧内容 */} - - - {/* 右侧内容 */} - {selectedPlan && priceInfo && ( - setCouponSelectorOpen(true)} - /> - )} -
-
- )} -
- - {/* 支付弹窗 */} - {selectedPlan && priceInfo && ( - - )} - - {/* 充值弹窗 */} - - - {/* 优惠券选择弹窗 */} - -
- ); -}; - -// 在模块加载时贡献路由 -try { - extensionRegistry.contribute('routes', { - contributorId: 'plan-selection', - value: { - path: '/plans', - Component: PlanSelectionPage, - }, - priority: 10, - }); - console.log('[plan-selection] Route contributed at module load: /plans'); -} catch (error) { - console.warn('[plan-selection] Failed to contribute route at module load:', error); -} - -try { - extensionRegistry.contribute('i18n:resources', { - contributorId: 'plan-selection', - value: { - namespace: 'plans', - resources: planResources, - }, - priority: 10, - }); -} catch (error) { - console.warn('[plan-selection] Failed to contribute i18n resources:', error); -} - -const planSelectionPlugin = { - id: 'plan-selection', - name: 'Plan Selection', - version: '1.0.0', - component: PlanSelectionPage, - slots: [], -}; - -export default planSelectionPlugin; diff --git a/plugins/pages/plan-selection/src/types/index.ts b/plugins/pages/plan-selection/src/types/index.ts deleted file mode 100644 index bc7fb181..00000000 --- a/plugins/pages/plan-selection/src/types/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -export interface PlanFeature { - id: string; - name: string; - description?: string; -} - -export interface CalculatedPriceInfo { - original_price: number | string; - plan_discount: number | string; - coupon_discount: number | string; - final_price: number | string; - total_saved: number | string; - billing_period: string; -} - -export interface BillingPlan { - id: string; - name: string; - pricePerMonth: number; - pricePerYear?: number; - currency: string; - environmentsLimit: number; - description: string; - features: PlanFeature[]; - discount?: { - monthly?: number; - yearly?: number; - }; - popular?: boolean; - badge?: string; - calculatedPrice?: CalculatedPriceInfo | null; -} - -export interface CurrentPlan { - id: string; - expiresAt?: string; - environmentsUsed: number; - maxEnvironments: number; -} - -export interface ReferralPlanSummaryView { - referralValueLast30Days: number; - currentPlanMonthlyPrice?: number | null; - coverageRatio?: number | null; -} - -export interface PlanPriceInfo { - monthlyPrice: number; - originalPrice: number; - discountPercent: number | null; - actualPrice: number; - savedAmount: number; -} diff --git a/plugins/pages/plan-selection/src/utils/plan-price.ts b/plugins/pages/plan-selection/src/utils/plan-price.ts deleted file mode 100644 index 9ce89aae..00000000 --- a/plugins/pages/plan-selection/src/utils/plan-price.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Check, Users, Shield, Zap } from 'lucide-react'; -import type { BillingPlan, PlanPriceInfo } from '../types'; - -/** - * 获取功能图标 - */ -export function getFeatureIcon(featureId: string) { - if (featureId.includes('env')) return Users; - if (featureId.includes('support')) return Shield; - if (featureId.includes('api') || featureId.includes('rpa')) return Zap; - return Check; -} - -/** - * 获取套餐的月度价格 - */ -export function getMonthlyPrice(plan: BillingPlan): number { - return plan.pricePerMonth; -} - -/** - * 获取套餐的原价 - */ -export function getOriginalPrice(plan: BillingPlan): number { - return plan.pricePerMonth; -} - -/** - * 获取折扣百分比 - */ -export function getDiscountPercent(plan: BillingPlan): number | null { - return plan.discount?.monthly || null; -} - -/** - * 获取实际价格(考虑折扣后) - */ -export function getActualPrice(plan: BillingPlan): number { - if (plan.discount?.monthly) { - return plan.pricePerMonth * (1 - plan.discount.monthly / 100); - } - return plan.pricePerMonth; -} - -/** - * 获取节省金额 - */ -export function getSavedAmount(plan: BillingPlan): number { - if (plan.discount?.monthly) { - return plan.pricePerMonth - getActualPrice(plan); - } - return 0; -} - -/** - * 获取套餐价格信息 - */ -export function getPlanPriceInfo(plan: BillingPlan): PlanPriceInfo { - // 如果套餐有计算后的价格(来自后端),优先使用 - if (plan.calculatedPrice) { - const cp = plan.calculatedPrice; - const originalPrice = typeof cp.original_price === 'string' ? parseFloat(cp.original_price) : cp.original_price; - const finalPrice = typeof cp.final_price === 'string' ? parseFloat(cp.final_price) : cp.final_price; - const planDiscount = typeof cp.plan_discount === 'string' ? parseFloat(cp.plan_discount) : cp.plan_discount; - const couponDiscount = typeof cp.coupon_discount === 'string' ? parseFloat(cp.coupon_discount) : cp.coupon_discount; - const totalSaved = typeof cp.total_saved === 'string' ? parseFloat(cp.total_saved) : cp.total_saved; - - // 计算折扣百分比(基于套餐级折扣) - const discountPercent = originalPrice > 0 ? (planDiscount / originalPrice) * 100 : null; - - return { - monthlyPrice: finalPrice, - originalPrice: originalPrice, - discountPercent: discountPercent, - actualPrice: finalPrice, - savedAmount: totalSaved, - }; - } - - // 否则使用前端计算逻辑 - return { - monthlyPrice: getMonthlyPrice(plan), - originalPrice: getOriginalPrice(plan), - discountPercent: getDiscountPercent(plan), - actualPrice: getActualPrice(plan), - savedAmount: getSavedAmount(plan), - }; -} diff --git a/plugins/pages/plan-selection/src/utils/plan-transform.ts b/plugins/pages/plan-selection/src/utils/plan-transform.ts deleted file mode 100644 index 8b4aa4fb..00000000 --- a/plugins/pages/plan-selection/src/utils/plan-transform.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { PlanDto, PlanFeatureDto, PlanWithFeatures } from '../api/index.types'; -import type { BillingPlan, PlanFeature } from '../types'; - -/** - * 将后端 PlanFeatureDto 转换为前端 PlanFeature - */ -export function transformPlanFeatureDto(dto: PlanFeatureDto): PlanFeature { - return { - id: dto.feature_key, - name: dto.feature_name, - description: dto.feature_value || undefined, - }; -} - -/** - * 将后端 PlanWithFeatures 转换为前端 BillingPlan - */ -export function transformPlanWithFeatures(pwf: PlanWithFeatures): BillingPlan { - const dto = pwf.plan; - return { - id: dto.uuid, - name: dto.name, - pricePerMonth: Number(dto.price_per_month), - pricePerYear: Number(dto.price_per_year), - currency: dto.currency || 'USD', - environmentsLimit: dto.max_environments, - description: dto.description || '', - features: pwf.features.map(transformPlanFeatureDto), - popular: dto.is_recommended ?? false, - badge: dto.is_recommended ? 'popular' : undefined, - discount: dto.discount_monthly || dto.discount_yearly - ? { - monthly: dto.discount_monthly ? Number(dto.discount_monthly) : undefined, - yearly: dto.discount_yearly ? Number(dto.discount_yearly) : undefined, - } - : undefined, - calculatedPrice: pwf.calculated_price || undefined, - }; -} - diff --git a/plugins/pages/plan-selection/tsconfig.json b/plugins/pages/plan-selection/tsconfig.json deleted file mode 100644 index f5b67230..00000000 --- a/plugins/pages/plan-selection/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "jsx": "react-jsx" - }, - "include": ["src/**/*"] -} diff --git a/plugins/pages/referral-program/manifest.json b/plugins/pages/referral-program/manifest.json deleted file mode 100644 index 9872f86b..00000000 --- a/plugins/pages/referral-program/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "referral-program", - "name": "Referral Program", - "version": "1.0.0", - "description": "推广计划页面插件", - "author": "Simprint Team", - "entry": "./src/index.tsx", - "slots": [], - "enabled": true -} diff --git a/plugins/pages/referral-program/package.json b/plugins/pages/referral-program/package.json deleted file mode 100644 index c5463ba8..00000000 --- a/plugins/pages/referral-program/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "plugin-referral-program", - "version": "1.0.0", - "main": "src/index.tsx", - "private": true -} diff --git a/plugins/pages/referral-program/src/api/index.ts b/plugins/pages/referral-program/src/api/index.ts deleted file mode 100644 index 51c0fce4..00000000 --- a/plugins/pages/referral-program/src/api/index.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * 推广计划 API 实现 - * - * - 完全参考 account-center 的分层结构 - * - 只暴露「业务模型」和调用函数,隐藏 DTO 细节 - */ - -import { post, isSuccess } from '@/lib/request'; -import { API_ENDPOINTS, ITEMS_PER_PAGE } from '../constants'; - -import type { - ReferralLink, - ReferralReward, - ReferredUser, - ReferralStats, -} from '../types'; - -import type { - ReferralDashboardDto, - ReferralDashboard, - ReferralPointsSummary, - ListReferralRewardsRequest, - ReferralRewardsListResponse, - ListReferredUsersRequest, - ReferredUsersListResponse, - SwitchReferralLinkRequest, - RedeemPointsRequest, -} from './index.types'; - -// ============ DTO → 前端模型转换 ============ - -function transformReferralLinkDto( - dto: { - uuid: string; - code: string; - url?: string | null; - unlocked?: boolean | null; - reward_rate?: string | number | null; - discount_rate?: string | number | null; - name?: string | null; - description?: string | null; - }, - index: number, -): ReferralLink { - // 如果后端未返回完整 URL,则在前端根据邀请码 code 构造一个注册页链接 - // 目前注册路由为 /auth/register,推荐码字段为 referral_code - const fallbackUrl = - (typeof window !== 'undefined' - ? `${window.location.origin}/auth/register?referral_code=${encodeURIComponent(dto.code)}` - : '') || ''; - - return { - id: dto.uuid, - name: dto.name?.trim() || '', - code: dto.code, - url: dto.url || fallbackUrl, - unlocked: Boolean(dto.unlocked), - rewardRate: Number(dto.reward_rate ?? 0), - discountRate: Number(dto.discount_rate ?? 0), - description: dto.description || undefined, - }; -} - -function transformDashboardDtoToView(dto: ReferralDashboardDto): ReferralDashboard { - const links: ReferralLink[] = dto.links.map((link, index) => - transformReferralLinkDto(link, index), - ); - - const currentLink: ReferralLink | null = dto.current_link - ? transformReferralLinkDto(dto.current_link, 0) - : null; - - const points: ReferralPointsSummary = { - availablePoints: dto.points.available_points, - pendingPoints: dto.points.pending_points, - totalRewards: dto.points.total_rewards, - }; - - // 这里的统计字段目前后端还未完全对齐前端的 mock 结构, - // 先做一个相对合理的映射,后续可以根据实际接口再调整。 - const stats: ReferralStats = { - code: currentLink?.code || '', - currentLinkId: currentLink?.id || '', - availablePoints: points.availablePoints, - pendingPoints: points.pendingPoints, - totalEarnedPoints: points.totalRewards, - // 下列字段暂时从 dto.stats 推导或兜底为 0 - linkClicks: dto.stats.total_referrals || 0, - registeredUsers: dto.stats.total_referrals || 0, - paidUsers: dto.stats.paid_referrals || 0, - last30DaysConsumption: Number(dto.stats.last_30_days_consumption || 0), - links, - }; - - return { - stats, - links, - currentLink, - points, - }; -} - -// ============ 实际接口调用 ============ - -/** - * 获取推广看板聚合数据 - */ -export async function getReferralDashboard(): Promise { - const result = await post(API_ENDPOINTS.DASHBOARD, {}); - - if (!isSuccess(result)) { - throw new Error(result.message || '获取推广看板失败'); - } - - if (!result.data) { - throw new Error('推广看板响应数据为空'); - } - - return transformDashboardDtoToView(result.data); -} - -/** - * 查询奖励记录列表 - */ -export async function listReferralRewards( - params: Omit, -): Promise { - const payload: ListReferralRewardsRequest = { - page: params.page, - page_size: ITEMS_PER_PAGE, - keyword: params.keyword ?? null, - reward_type: params.reward_type ?? null, - status: params.status ?? null, - }; - - const result = await post( - API_ENDPOINTS.REWARDS, - payload, - ); - - if (!isSuccess(result)) { - throw new Error(result.message || '获取推广奖励列表失败'); - } - - return ( - result.data || { - items: [], - total: 0, - page: payload.page, - page_size: payload.page_size, - } - ); -} - -/** - * 查询被邀请用户列表 - */ -export async function listReferredUsers( - params: Omit, -): Promise { - const payload: ListReferredUsersRequest = { - page: params.page, - page_size: ITEMS_PER_PAGE, - keyword: params.keyword ?? null, - status: params.status ?? null, - }; - - const result = await post( - API_ENDPOINTS.REFERRED_USERS, - payload, - ); - - if (!isSuccess(result)) { - throw new Error(result.message || '获取被邀请用户列表失败'); - } - - return ( - result.data || { - items: [], - total: 0, - page: payload.page, - page_size: payload.page_size, - } - ); -} - -/** - * 切换当前推广链接 - */ -export async function switchReferralLink( - payload: SwitchReferralLinkRequest, -): Promise { - const result = await post(API_ENDPOINTS.SWITCH_LINK, payload); - if (!isSuccess(result)) { - throw new Error(result.message || '切换推广链接失败'); - } -} - -/** - * 兑换推广积分 - */ -export async function redeemReferralPoints( - payload: RedeemPointsRequest, -): Promise { - const result = await post(API_ENDPOINTS.REDEEM, payload); - if (!isSuccess(result)) { - throw new Error(result.message || '兑换推广积分失败'); - } -} - -// 类型再导出,方便外部直接从 ../api 引入 -export type { - ReferralDashboard, - ReferralPointsSummary, - ListReferralRewardsRequest, - ReferralRewardsListResponse, - ListReferredUsersRequest, - ReferredUsersListResponse, - SwitchReferralLinkRequest, - RedeemPointsRequest, -} from './index.types'; - diff --git a/plugins/pages/referral-program/src/api/index.types.ts b/plugins/pages/referral-program/src/api/index.types.ts deleted file mode 100644 index de663829..00000000 --- a/plugins/pages/referral-program/src/api/index.types.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * 推广计划 API 类型定义 - */ - -import type { - ReferralLink, - ReferralReward, - ReferredUser, - ReferralStats, -} from '../types'; - -// ============ 后端 DTO / 请求 / 响应类型 ============ - -/** - * 推广看板聚合响应(后端原始结构,尽量贴合 Rust `ReferralDashboardResponse`) - */ -export interface ReferralDashboardDto { - stats: { - total_referrals: number; - paid_referrals: number; - total_consumption: string; - last_30_days_consumption: string; - total_rewards: number; - available_points: number; - // 当前层级等字段前端暂不直接使用,保留以便后续扩展 - current_tier?: unknown; - next_tier?: unknown; - upgrade_progress?: number; - }; - links: Array<{ - uuid: string; - code: string; - url?: string | null; - unlocked?: boolean | null; - reward_rate?: string | number | null; - discount_rate?: string | number | null; - // 后端目前没有 name / description,预留字段 - name?: string | null; - description?: string | null; - }>; - current_link: { - uuid: string; - code: string; - url?: string | null; - unlocked?: boolean | null; - reward_rate?: string | number | null; - discount_rate?: string | number | null; - name?: string | null; - description?: string | null; - } | null; - tiers: unknown[]; - points: { - available_points: number; - pending_points: number; - total_rewards: number; - }; -} - -/** 看板积分摘要(前端展示用) */ -export interface ReferralPointsSummary { - availablePoints: number; - pendingPoints: number; - totalRewards: number; -} - -/** 看板聚合(前端展示用) */ -export interface ReferralDashboard { - stats: ReferralStats; - links: ReferralLink[]; - currentLink: ReferralLink | null; - points: ReferralPointsSummary; -} - -/** 查询奖励记录请求(对应 `ListReferralRewardsRequest`) */ -export interface ListReferralRewardsRequest { - page: number; - page_size: number; - keyword?: string | null; - reward_type?: string | null; - status?: string | null; -} - -/** 奖励记录列表响应(后端原始结构) */ -export interface ReferralRewardsListResponse { - items: ReferralReward[]; - total: number; - page: number; - page_size: number; -} - -/** 查询被邀请用户请求(对应 `ListReferredUsersRequest`) */ -export interface ListReferredUsersRequest { - page: number; - page_size: number; - keyword?: string | null; - status?: string | null; -} - -/** 被邀请用户列表响应(后端原始结构) */ -export interface ReferredUsersListResponse { - items: ReferredUser[]; - total: number; - page: number; - page_size: number; -} - -/** 切换链接请求(对应 `SwitchReferralLinkRequest`) */ -export interface SwitchReferralLinkRequest { - link_uuid: string; -} - -/** 兑换积分请求(当前前端约定:points + type) */ -export interface RedeemPointsRequest { - points: number; - type: 'quota' | 'feature' | 'duration'; -} - diff --git a/plugins/pages/referral-program/src/components/overview-tab.tsx b/plugins/pages/referral-program/src/components/overview-tab.tsx deleted file mode 100644 index 738a79c1..00000000 --- a/plugins/pages/referral-program/src/components/overview-tab.tsx +++ /dev/null @@ -1,287 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useMemo } from 'react'; -import { - Copy, - Users, - MousePointerClick, - DollarSign, - Clock, - CheckCircle2, - Lock, - Radio, -} from 'lucide-react'; -import { RedeemableRewardCard } from './redeemable-reward-card'; -import type { ReferralStats, ReferralLink } from '../types'; - -interface OverviewTabProps { - stats: ReferralStats; - currentLink: ReferralLink | null; - onRedeem: () => void; - onSwitchLink: (linkId: string) => Promise; - onCopy: (text: string, label: string) => Promise; -} - -export const OverviewTab: React.FC = ({ - stats, - currentLink, - onRedeem, - onSwitchLink, - onCopy, -}) => { - const { t } = useTranslation('referral'); - - // 根据链接ID确定解锁条件(由后端配置的 description 驱动) - const getUnlockCondition = (linkId: string): string => { - const link = stats.links.find((l) => l.id === linkId); - if (link?.description) { - return link.description; - } - // 默认回退为通用文案,由 i18n 自行决定内容 - return t('overview.defaultUnlockCondition'); - }; - - // 统计数据配置 - const statsData = useMemo(() => { - const maxValue = Math.max( - stats.linkClicks, - stats.registeredUsers, - stats.last30DaysConsumption, - stats.pendingPoints, - 1 // 避免除零 - ); - - return [ - { - label: t('overview.stats.linkClicks'), - value: stats.linkClicks, - icon: MousePointerClick, - color: 'bg-primary', - }, - { - label: t('overview.stats.registeredUsers'), - value: stats.registeredUsers, - icon: Users, - color: 'bg-primary', - }, - { - label: t('overview.stats.last30DaysConsumption'), - value: stats.last30DaysConsumption, - icon: DollarSign, - color: 'bg-primary', - format: (v: number) => `$${v.toFixed(2)}`, - }, - { - label: t('overview.stats.underReview'), - value: stats.pendingPoints, - icon: Clock, - color: 'bg-primary', - }, - ].map((item) => { - const heightPercent = Math.max((item.value / maxValue) * 100, 0); - const barHeight = Math.max(heightPercent, 2); - const valueBottom = barHeight; - return { ...item, barHeight, valueBottom }; - }); - }, [stats, t]); - - return ( -
-
-
- {/* 顶部:可兑换奖励 + 统计卡片 + 奖励规则 */} -
- {/* 可兑换奖励 - 占据两行,带Canvas背景和图标背景 */} - - - {/* 统计卡片 - 链接点击 */} -
-
- -
- {t('overview.stats.linkClicks')} -
-
-
{stats.linkClicks}
-
- - {/* 充值用户和名下用户(两个独立卡片) */} -
-
- {/* 充值用户 */} -
-
- -
- {t('overview.stats.paidUsers')} -
-
-
{stats.paidUsers}
-
- - {/* 名下用户 */} -
-
- -
- {t('overview.stats.registeredUsers')} -
-
-
{stats.registeredUsers}
-
-
-
- - {/* 总获得奖励 */} -
-
- {t('overview.stats.totalEarned')} -
-
{stats.totalEarnedPoints}
-
- - {/* 奖励规则卡片(末尾,上下布局) */} - {currentLink && ( -
-
- {t('overview.rewardRules')} -
-
-
-
- {t('overview.youWillGet')} -
-
{currentLink.rewardRate}%
-
-
-
- {t('overview.theyWillGet')} -
-
- {currentLink.discountRate}% -
-
-
-
- )} -
- - {/* 主要内容区域:左侧推广链接选择,右侧详情 */} -
- {/* 左侧:推广链接选择(田字布局) */} -
-

{t('overview.selectLink')}

-
- {stats.links.map((link, index) => { - const isSelected = link.id === stats.currentLinkId; - const linkDisplayName = - link.name || t('overview.linkFallback', { index: index + 1 }); - return ( -
{ - if (link.unlocked && !isSelected) { - void onSwitchLink(link.id); - } - }} - > - {/* 复制按钮(右上角) */} - {link.unlocked && ( - - )} - -
-
- {isSelected ? ( - - ) : ( -
- )} - - {linkDisplayName} - -
- {!link.unlocked && } -
- {!link.unlocked && ( -
- {getUnlockCondition(link.id)} -
- )} -
- ); - })} -
-
- - {/* 右侧:数据统计(垂直柱状图) */} -
-

{t('overview.dataStats')}

-
- {statsData.map((item, index) => { - const Icon = item.icon; - return ( -
- {/* 垂直柱子容器 */} -
- {/* 数值显示 - 在柱子顶部 */} -
- {item.format ? item.format(item.value) : item.value} -
- - {/* 垂直柱子 */} -
-
-
-
- - {/* 标签 */} -
- - - {item.label} - -
-
- ); - })} -
-
-
-
-
-
- ); -}; diff --git a/plugins/pages/referral-program/src/components/redeem-dialog.tsx b/plugins/pages/referral-program/src/components/redeem-dialog.tsx deleted file mode 100644 index a7282f66..00000000 --- a/plugins/pages/referral-program/src/components/redeem-dialog.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import type { ReferralStats, RedeemType } from '../types'; - -interface RedeemDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - stats: ReferralStats | null; - redeemPoints: string; - redeemType: RedeemType; - onRedeemPointsChange: (points: string) => void; - onRedeemTypeChange: (type: RedeemType) => void; - onConfirm: () => void; -} - -export const RedeemDialog: React.FC = ({ - open, - onOpenChange, - stats, - redeemPoints, - redeemType, - onRedeemPointsChange, - onRedeemTypeChange, - onConfirm, -}) => { - const { t } = useTranslation('referral'); - - return ( - - - - {t('redeem.title')} - - {t('redeem.description')} - - -
-
- - onRedeemPointsChange(e.target.value)} - placeholder="输入兑换点数" - className="text-xs" - /> -
- 可用点数: {stats?.availablePoints ?? 0} -
-
-
- - -
-
- - - - -
-
- ); -}; diff --git a/plugins/pages/referral-program/src/components/redeemable-reward-card.tsx b/plugins/pages/referral-program/src/components/redeemable-reward-card.tsx deleted file mode 100644 index 2ddd21cc..00000000 --- a/plugins/pages/referral-program/src/components/redeemable-reward-card.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { useRef, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Gift, Clock, Sparkles, TrendingUp } from 'lucide-react'; -import { Button } from '@/components/ui/button'; - -interface RedeemableRewardCardProps { - availablePoints: number; - pendingPoints: number; - onRedeem: () => void; -} - -export const RedeemableRewardCard: React.FC = ({ - availablePoints, - pendingPoints, - onRedeem, -}) => { - const { t } = useTranslation('referral'); - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const drawPattern = () => { - const dpr = window.devicePixelRatio || 1; - const rect = canvas.getBoundingClientRect(); - const width = rect.width; - const height = rect.height; - - canvas.width = width * dpr; - canvas.height = height * dpr; - ctx.scale(dpr, dpr); - canvas.style.width = width + 'px'; - canvas.style.height = height + 'px'; - - ctx.clearRect(0, 0, width, height); - - // 绘制网格背景 - const gridSize = 16; - ctx.strokeStyle = 'rgba(0, 0, 0, 0.04)'; - ctx.lineWidth = 0.5; - - for (let x = 0; x <= width; x += gridSize) { - ctx.beginPath(); - ctx.moveTo(x + 0.5, 0); - ctx.lineTo(x + 0.5, height); - ctx.stroke(); - } - - for (let y = 0; y <= height; y += gridSize) { - ctx.beginPath(); - ctx.moveTo(0, y + 0.5); - ctx.lineTo(width, y + 0.5); - ctx.stroke(); - } - - // 绘制装饰性渐变圆圈 - const circles = [ - { x: width * 0.2, y: height * 0.15, radius: 50, opacity: 0.08 }, - { x: width * 0.8, y: height * 0.25, radius: 60, opacity: 0.06 }, - { x: width * 0.75, y: height * 0.75, radius: 40, opacity: 0.07 }, - ]; - - circles.forEach((circle) => { - const gradient = ctx.createRadialGradient( - circle.x, - circle.y, - 0, - circle.x, - circle.y, - circle.radius - ); - gradient.addColorStop(0, `rgba(0, 0, 0, ${circle.opacity})`); - gradient.addColorStop(0.5, `rgba(0, 0, 0, ${circle.opacity * 0.5})`); - gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); - - ctx.beginPath(); - ctx.arc(circle.x, circle.y, circle.radius, 0, Math.PI * 2); - ctx.fillStyle = gradient; - ctx.fill(); - }); - - // 绘制点阵装饰 - ctx.fillStyle = 'rgba(0, 0, 0, 0.02)'; - const dotSpacing = 24; - for (let x = dotSpacing; x < width; x += dotSpacing) { - for (let y = dotSpacing; y < height; y += dotSpacing) { - if (Math.random() > 0.85) { - ctx.beginPath(); - ctx.arc(x, y, 1, 0, Math.PI * 2); - ctx.fill(); - } - } - } - }; - - // 初始绘制 - drawPattern(); - - // 监听窗口大小变化 - const resizeObserver = new ResizeObserver(() => { - drawPattern(); - }); - resizeObserver.observe(canvas); - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - return ( -
- {/* Canvas 背景 */} - - - {/* 图标背景 */} -
- - - -
- - {/* 内容层 */} -
-
- {/* 顶部区域 */} -
-
- {/* 标题 */} -
- {t('overview.withdrawableBalance')} -
- {/* 奖励点数 */} -
{availablePoints}
-
- {/* 图标 */} -
- -
-
- - {/* 待审核奖励提示 */} - {pendingPoints > 0 && ( -
-
- - - {t('overview.pendingRewards')} - -
-
- {pendingPoints} {t('overview.stats.underReview')} -
-
- )} - - {/* 激励描述文本 */} -

- {t('overview.withdrawableBalanceMotivation')} -

-
- - {/* 底部按钮 */} - -
-
- ); -}; diff --git a/plugins/pages/referral-program/src/components/referral-page-skeleton.tsx b/plugins/pages/referral-program/src/components/referral-page-skeleton.tsx deleted file mode 100644 index 1ba09d26..00000000 --- a/plugins/pages/referral-program/src/components/referral-page-skeleton.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -export function ReferralPageSkeleton() { - return ( -
-
-
- {/* 顶部:可兑换奖励 + 统计卡片 + 奖励规则 */} -
- {/* 可兑换奖励卡片 - 占据两行 */} -
- - - - -
- - {/* 统计卡片 - 链接点击 */} -
- - -
- - {/* 充值用户和名下用户(两个独立卡片) */} -
-
-
- - -
-
- - -
-
-
- - {/* 总获得奖励 */} -
- - -
-
- - {/* 中间:推广链接和图表 */} -
- {/* 左侧:推广链接选择 */} -
- -
- {Array.from({ length: 4 }).map((_, index) => ( -
- - - -
- ))} -
-
- - {/* 右侧:图表 */} -
- - -
-
- - {/* 底部:推广横幅 */} -
- -
- - -
- - -
-
-
-
-
-
- ); -} diff --git a/plugins/pages/referral-program/src/components/referral-pagination.tsx b/plugins/pages/referral-program/src/components/referral-pagination.tsx deleted file mode 100644 index a4cae77a..00000000 --- a/plugins/pages/referral-program/src/components/referral-pagination.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { - Pagination, - PaginationContent, - PaginationEllipsis, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from '@/components/ui/pagination'; - -interface ReferralPaginationProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; - namespace: 'rewards' | 'users'; -} - -export const ReferralPagination: React.FC = ({ - currentPage, - totalPages, - onPageChange, - namespace, -}) => { - const { t } = useTranslation('referral'); - const safeTotalPages = Math.max(totalPages, 1); - const safeCurrentPage = Math.min(Math.max(currentPage, 1), safeTotalPages); - - const getPageNumbers = () => { - const pages: (number | 'ellipsis')[] = []; - const maxVisible = 7; - - if (safeTotalPages <= maxVisible) { - for (let i = 1; i <= safeTotalPages; i++) { - pages.push(i); - } - } else { - if (safeCurrentPage <= 3) { - for (let i = 1; i <= 4; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(safeTotalPages); - } else if (safeCurrentPage >= safeTotalPages - 2) { - pages.push(1); - pages.push('ellipsis'); - for (let i = safeTotalPages - 3; i <= safeTotalPages; i++) pages.push(i); - } else { - pages.push(1); - pages.push('ellipsis'); - for (let i = safeCurrentPage - 1; i <= safeCurrentPage + 1; i++) pages.push(i); - pages.push('ellipsis'); - pages.push(safeTotalPages); - } - } - - return pages; - }; - - return ( -
-
- {t(`${namespace}.pagination.pageInfo`, { - current: safeCurrentPage, - total: safeTotalPages, - })} -
- - - - { - e.preventDefault(); - if (safeCurrentPage > 1) onPageChange(safeCurrentPage - 1); - }} - className={safeCurrentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'} - > - {t(`${namespace}.pagination.previous`)} - - - - {getPageNumbers().map((page, index) => ( - - {page === 'ellipsis' ? ( - - ) : ( - { - e.preventDefault(); - onPageChange(page); - }} - isActive={page === safeCurrentPage} - className="cursor-pointer" - > - {page} - - )} - - ))} - - - { - e.preventDefault(); - if (safeCurrentPage < safeTotalPages) onPageChange(safeCurrentPage + 1); - }} - className={ - safeCurrentPage === safeTotalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer' - } - > - {t(`${namespace}.pagination.next`)} - - - - -
- ); -}; diff --git a/plugins/pages/referral-program/src/components/referral-table-skeleton.tsx b/plugins/pages/referral-program/src/components/referral-table-skeleton.tsx deleted file mode 100644 index 82497613..00000000 --- a/plugins/pages/referral-program/src/components/referral-table-skeleton.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -interface ReferralTableSkeletonProps { - rows?: number; -} - -export function ReferralTableSkeleton({ rows = 8 }: ReferralTableSkeletonProps) { - return ( - <> - {Array.from({ length: rows }).map((_, index) => ( - - {/* 序号列 */} - - - - {/* 邮箱列 */} - - - - {/* 注册时间列 */} - - - - {/* 状态列 */} - - - - {/* 奖励列 */} - - - - {/* 链接列 */} - - - - {/* 操作列 */} - - - - - ))} - - ); -} diff --git a/plugins/pages/referral-program/src/components/referral-tabs.tsx b/plugins/pages/referral-program/src/components/referral-tabs.tsx deleted file mode 100644 index 8d446094..00000000 --- a/plugins/pages/referral-program/src/components/referral-tabs.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { cn } from '@/lib/utils'; -import type { ReferralTab } from '../types'; - -interface ReferralTabsProps { - activeTab: ReferralTab; - onTabChange: (tab: ReferralTab) => void; -} - -export const ReferralTabs: React.FC = ({ activeTab, onTabChange }) => { - const { t } = useTranslation('referral'); - - const tabs: { value: ReferralTab; key: string }[] = [ - { value: 'overview', key: 'tabs.overview' }, - { value: 'rewards', key: 'tabs.rewards' }, - { value: 'users', key: 'tabs.users' }, - ]; - - return ( -
-
- {tabs.map((tab) => ( - - ))} -
-
- ); -}; diff --git a/plugins/pages/referral-program/src/components/reward-table-skeleton.tsx b/plugins/pages/referral-program/src/components/reward-table-skeleton.tsx deleted file mode 100644 index a9fbc287..00000000 --- a/plugins/pages/referral-program/src/components/reward-table-skeleton.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -interface RewardTableSkeletonProps { - rows?: number; -} - -export function RewardTableSkeleton({ rows = 8 }: RewardTableSkeletonProps) { - return ( - <> - {Array.from({ length: rows }).map((_, index) => ( - - {/* 序号列 */} - - - - {/* 日期列 */} - - - - {/* 类型列 */} - - - - {/* 描述列 */} - - - - {/* 积分列 */} - - - - {/* 状态列 */} - - - - {/* 用户列 */} - - - - - ))} - - ); -} diff --git a/plugins/pages/referral-program/src/components/rewards-tab.tsx b/plugins/pages/referral-program/src/components/rewards-tab.tsx deleted file mode 100644 index 5a5e7c6a..00000000 --- a/plugins/pages/referral-program/src/components/rewards-tab.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - Gift, - RefreshCw, - Clock, - CheckCircle2, - XCircle, - ChevronDown, - X, - Search, -} from 'lucide-react'; -import { Input } from '@/components/ui/input'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; -import { ReferralPagination } from './referral-pagination'; -import { RewardTableSkeleton } from './reward-table-skeleton'; -import type { ReferralReward, RewardStatusFilter } from '../types'; -import { ITEMS_PER_PAGE } from '../constants'; - -interface RewardsTabProps { - rewards: ReferralReward[]; - loading: boolean; - totalPages: number; - currentPage: number; - statusFilter: RewardStatusFilter; - typeFilter: string; - searchQuery: string; - rewardTypes: string[]; - onRefresh: () => Promise; - onPageChange: (page: number) => void; - onStatusFilterChange: (filter: RewardStatusFilter) => void; - onTypeFilterChange: (filter: string) => void; - onSearchChange: (query: string) => void; -} - -export const RewardsTab: React.FC = ({ - rewards, - loading, - totalPages, - currentPage, - statusFilter, - typeFilter, - searchQuery, - rewardTypes, - onRefresh, - onPageChange, - onStatusFilterChange, - onTypeFilterChange, - onSearchChange, -}) => { - const { t } = useTranslation('referral'); - const [searchValue, setSearchValue] = useState(searchQuery); - const searchChangeRef = useRef(onSearchChange); - - useEffect(() => { - searchChangeRef.current = onSearchChange; - }, [onSearchChange]); - - useEffect(() => { - setSearchValue(searchQuery); - }, [searchQuery]); - - useEffect(() => { - const timer = window.setTimeout(() => { - searchChangeRef.current(searchValue); - }, 300); - - return () => window.clearTimeout(timer); - }, [searchValue]); - - return ( -
- {/* 顶部搜索和筛选栏 */} -
-
-
- - setSearchValue(e.target.value)} - className="h-9 w-72 pl-8 text-xs" - /> -
-
-
- -
-
- - {/* 表格 */} -
- -
- - - - - - - - - - - - - - {loading ? ( - - ) : rewards.length === 0 ? ( - - - - ) : ( - rewards.map((reward, index) => ( - - - - - - - - - - )) - )} - -
- {t('rewards.table.index')} - - {t('rewards.table.date')} - -
- {t('rewards.table.type')} - {!typeFilter ? ( - - - - - - {rewardTypes.map((type) => ( - onTypeFilterChange(type)} - className="text-xs cursor-pointer" - > - {t(`rewards.types.${type}`)} - - ))} - - - ) : ( - <> - - {t(`rewards.types.${typeFilter}`)} - - - - )} -
-
- {t('rewards.table.description')} - - {t('rewards.table.points')} - -
- {t('rewards.table.status')} - {statusFilter === 'all' ? ( - - - - - - onStatusFilterChange('pending')} - className="text-xs cursor-pointer" - > - {t('rewards.status.pending')} - - onStatusFilterChange('approved')} - className="text-xs cursor-pointer" - > - {t('rewards.status.approved')} - - onStatusFilterChange('rejected')} - className="text-xs cursor-pointer" - > - {t('rewards.status.rejected')} - - - - ) : ( - <> - - {t(`rewards.status.${statusFilter}`)} - - - - )} -
-
- {t('rewards.table.user')} -
- -
{t('rewards.noData')}
-
-
- {(currentPage - 1) * ITEMS_PER_PAGE + index + 1} -
-
-
- {new Date(reward.createdAt).toLocaleDateString('zh-CN')} -
-
-
- {t(`rewards.types.${reward.type}`)} -
-
-
{reward.description}
-
-
{reward.points}
-
- {reward.status === 'pending' && ( - - - {t('rewards.status.pending')} - - )} - {reward.status === 'approved' && ( - - - {t('rewards.status.approved')} - - )} - {reward.status === 'rejected' && ( - - - {t('rewards.status.rejected')} - - )} - -
- {reward.referredUser || '-'} -
-
-
- -
-
- - {/* 分页 */} - { - if (page < 1 || page > Math.max(totalPages, 1)) return; - onPageChange(page); - }} - namespace="rewards" - /> -
- ); -}; diff --git a/plugins/pages/referral-program/src/components/users-tab.tsx b/plugins/pages/referral-program/src/components/users-tab.tsx deleted file mode 100644 index ed184fd5..00000000 --- a/plugins/pages/referral-program/src/components/users-tab.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Users, RefreshCw, ChevronDown, X, Search } from 'lucide-react'; -import { Input } from '@/components/ui/input'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; -import { ReferralPagination } from './referral-pagination'; -import { ReferralTableSkeleton } from './referral-table-skeleton'; -import type { ReferredUser, UserStatusFilter, ReferralLink } from '../types'; -import { ITEMS_PER_PAGE } from '../constants'; - -interface UsersTabProps { - users: ReferredUser[]; - loading: boolean; - totalPages: number; - currentPage: number; - statusFilter: UserStatusFilter; - searchQuery: string; - links: ReferralLink[]; - onRefresh: () => Promise; - onPageChange: (page: number) => void; - onStatusFilterChange: (filter: UserStatusFilter) => void; - onSearchChange: (query: string) => void; -} - -export const UsersTab: React.FC = ({ - users, - loading, - totalPages, - currentPage, - statusFilter, - searchQuery, - links, - onRefresh, - onPageChange, - onStatusFilterChange, - onSearchChange, -}) => { - const { t } = useTranslation('referral'); - const [searchValue, setSearchValue] = useState(searchQuery); - const searchChangeRef = useRef(onSearchChange); - - useEffect(() => { - searchChangeRef.current = onSearchChange; - }, [onSearchChange]); - - useEffect(() => { - setSearchValue(searchQuery); - }, [searchQuery]); - - useEffect(() => { - const timer = window.setTimeout(() => { - searchChangeRef.current(searchValue); - }, 300); - - return () => window.clearTimeout(timer); - }, [searchValue]); - - const formatMoney = (value: unknown) => { - const n = typeof value === 'number' ? value : Number(value); - return Number.isFinite(n) ? n.toFixed(2) : '--'; - }; - - const getLinkDisplayName = (linkId: string) => { - const linkIndex = links.findIndex((link) => link.id === linkId); - if (linkIndex < 0) return '-'; - - const link = links[linkIndex]; - return link.name || t('overview.linkFallback', { index: linkIndex + 1 }); - }; - - return ( -
- {/* 顶部搜索和筛选栏 */} -
-
-
- - setSearchValue(e.target.value)} - className="h-9 w-72 pl-8 text-xs" - /> -
-
-
- -
-
- - {/* 表格 */} -
- -
- - - - - - - - - - - - - - {loading ? ( - - ) : users.length === 0 ? ( - - - - ) : ( - users.map((user, index) => ( - - - - - - - - - - )) - )} - -
- {t('users.table.index')} - - {t('users.table.email')} - - {t('users.table.registeredAt')} - -
- {t('users.table.status')} - {statusFilter === 'all' ? ( - - - - - - onStatusFilterChange('registered')} - className="text-xs cursor-pointer" - > - {t('users.status.registered')} - - onStatusFilterChange('paid')} - className="text-xs cursor-pointer" - > - {t('users.status.paid')} - - onStatusFilterChange('active')} - className="text-xs cursor-pointer" - > - {t('users.status.active')} - - - - ) : ( - <> - - {t(`users.status.${statusFilter}`)} - - - - )} -
-
- {t('users.table.totalConsumption')} - - {t('users.table.last30DaysConsumption')} - - {t('users.table.link')} -
- -
{t('users.noData')}
-
-
- {(currentPage - 1) * ITEMS_PER_PAGE + index + 1} -
-
-
{user.email}
-
-
- {new Date(user.registeredAt).toLocaleDateString('zh-CN')} -
-
- - {t(`users.status.${user.status}`)} - - -
- ${formatMoney(user.totalConsumption)} -
-
-
- ${formatMoney(user.last30DaysConsumption)} -
-
-
- {getLinkDisplayName(user.linkId)} -
-
-
- -
-
- - {/* 分页 */} - { - if (page < 1 || page > Math.max(totalPages, 1)) return; - onPageChange(page); - }} - namespace="users" - /> -
- ); -}; diff --git a/plugins/pages/referral-program/src/constants.ts b/plugins/pages/referral-program/src/constants.ts deleted file mode 100644 index 9c779ff7..00000000 --- a/plugins/pages/referral-program/src/constants.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const ITEMS_PER_PAGE = 10; - -// 这里配置的是「业务路径」,由 Tauri 的 http_* 命令统一加上前缀等信息, -// 风格与 plan-selection / account-center 等页面保持一致。 -export const API_ENDPOINTS = { - STATS: 'referral/stats', - DASHBOARD: 'referral/dashboard', - REWARDS: 'referral/rewards', - REFERRED_USERS: 'referral/users', - SWITCH_LINK: 'referral/links/switch', - REDEEM: 'referral/redeem', -} as const; diff --git a/plugins/pages/referral-program/src/hooks/use-banner-sizes.ts b/plugins/pages/referral-program/src/hooks/use-banner-sizes.ts deleted file mode 100644 index 2b0afddf..00000000 --- a/plugins/pages/referral-program/src/hooks/use-banner-sizes.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useState, useEffect } from 'react'; -import type { PromoBannerSize } from '../types'; -import { get } from '@/lib/request'; -import { API_ENDPOINTS } from '../constants'; - -export interface UseBannerSizesReturn { - bannerSizes: PromoBannerSize[]; - selectedBannerSize: string; - setSelectedBannerSize: (size: string) => void; -} - -export function useBannerSizes(): UseBannerSizesReturn { - const [bannerSizes, setBannerSizes] = useState([]); - const [selectedBannerSize, setSelectedBannerSize] = useState('630x330'); - - useEffect(() => { - const fetchBannerSizes = async () => { - try { - const result = await get(API_ENDPOINTS.BANNER_SIZES); - if (!result || result.code !== 1) { - throw new Error(result?.message || '获取推广物料尺寸失败'); - } - const data = result.data ?? []; - setBannerSizes(data); - if (data.length > 0) { - setSelectedBannerSize(data[0].id); - } - } catch (e) { - console.error('Failed to fetch banner sizes:', e); - } - }; - void fetchBannerSizes(); - }, []); - - return { - bannerSizes, - selectedBannerSize, - setSelectedBannerSize, - }; -} diff --git a/plugins/pages/referral-program/src/hooks/use-referral-computed.ts b/plugins/pages/referral-program/src/hooks/use-referral-computed.ts deleted file mode 100644 index ec9539d2..00000000 --- a/plugins/pages/referral-program/src/hooks/use-referral-computed.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useMemo } from 'react'; -import type { ReferralStats, ReferralLink, ReferralReward } from '../types'; - -export interface UseReferralComputedParams { - stats: ReferralStats | null; - rewards: ReferralReward[]; -} - -export interface UseReferralComputedReturn { - currentLink: ReferralLink | null; - rewardTypes: string[]; -} - -/** - * 计算衍生数据的 Hook - */ -export function useReferralComputed(params: UseReferralComputedParams): UseReferralComputedReturn { - const { stats, rewards } = params; - - // 获取当前选中的推广链接 - const currentLink = useMemo(() => { - if (!stats) return null; - return stats.links.find((l) => l.id === stats.currentLinkId) || stats.links[0]; - }, [stats]); - - // 奖励类型选项 - const rewardTypes = useMemo(() => { - const types = new Set(rewards.map((r) => r.type)); - return Array.from(types); - }, [rewards]); - - return { - currentLink, - rewardTypes, - }; -} diff --git a/plugins/pages/referral-program/src/hooks/use-referral-dashboard.ts b/plugins/pages/referral-program/src/hooks/use-referral-dashboard.ts deleted file mode 100644 index 74debcdd..00000000 --- a/plugins/pages/referral-program/src/hooks/use-referral-dashboard.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import type { ReferralDashboard } from '../api'; -import { getReferralDashboard } from '../api'; - -export interface UseReferralDashboardReturn { - dashboard: ReferralDashboard | null; - loading: boolean; - error: string | null; - refresh: () => Promise; -} - -/** - * 使用后端聚合的推广看板接口。 - * - * 注意:当前后端的字段命名和前端 mock 类型可能不完全一致,这里做了最小映射: - * - ReferralStats 仍然作为前端展示的单一入口; - * - links / currentLink 从 dashboard.links / dashboard.current_link 映射; - * - points 从 dashboard.points 映射。 - */ -export function useReferralDashboard(): UseReferralDashboardReturn { - const [dashboard, setDashboard] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchDashboard = useCallback(async () => { - setLoading(true); - setError(null); - try { - const data = await getReferralDashboard(); - setDashboard(data); - } catch (e) { - setError(e instanceof Error ? e.message : '未知错误'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void fetchDashboard(); - }, [fetchDashboard]); - - return { - dashboard, - loading, - error, - refresh: fetchDashboard, - }; -} - diff --git a/plugins/pages/referral-program/src/hooks/use-referral-filters.ts b/plugins/pages/referral-program/src/hooks/use-referral-filters.ts deleted file mode 100644 index 63736bc7..00000000 --- a/plugins/pages/referral-program/src/hooks/use-referral-filters.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { RewardStatusFilter, UserStatusFilter } from '../types'; - -export interface UseReferralFiltersReturn { - // 奖励明细页过滤 - rewardPage: number; - setRewardPage: (page: number) => void; - rewardStatusFilter: RewardStatusFilter; - setRewardStatusFilter: (filter: RewardStatusFilter) => void; - rewardTypeFilter: string; - setRewardTypeFilter: (filter: string) => void; - rewardSearchQuery: string; - setRewardSearchQuery: (query: string) => void; - - // 被邀请用户页过滤 - userPage: number; - setUserPage: (page: number) => void; - userStatusFilter: UserStatusFilter; - setUserStatusFilter: (filter: UserStatusFilter) => void; - userSearchQuery: string; - setUserSearchQuery: (query: string) => void; -} - -export function useReferralFilters(): UseReferralFiltersReturn { - const [rewardPage, setRewardPage] = useState(1); - const [rewardStatusFilter, setRewardStatusFilter] = useState('all'); - const [rewardTypeFilter, setRewardTypeFilter] = useState(''); - const [rewardSearchQuery, setRewardSearchQuery] = useState(''); - - const [userPage, setUserPage] = useState(1); - const [userStatusFilter, setUserStatusFilter] = useState('all'); - const [userSearchQuery, setUserSearchQuery] = useState(''); - - const handleRewardSearchChange = useCallback((query: string) => { - setRewardSearchQuery(query); - setRewardPage(1); - }, []); - - const handleUserSearchChange = useCallback((query: string) => { - setUserSearchQuery(query); - setUserPage(1); - }, []); - - return { - rewardPage, - setRewardPage, - rewardStatusFilter, - setRewardStatusFilter, - rewardTypeFilter, - setRewardTypeFilter, - rewardSearchQuery: rewardSearchQuery, - setRewardSearchQuery: handleRewardSearchChange, - userPage, - setUserPage, - userStatusFilter, - setUserStatusFilter, - userSearchQuery: userSearchQuery, - setUserSearchQuery: handleUserSearchChange, - }; -} diff --git a/plugins/pages/referral-program/src/hooks/use-referral-handlers.ts b/plugins/pages/referral-program/src/hooks/use-referral-handlers.ts deleted file mode 100644 index a9f66d85..00000000 --- a/plugins/pages/referral-program/src/hooks/use-referral-handlers.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { toast } from 'sonner'; -import type { ReferralStats, RedeemType } from '../types'; -import { switchReferralLink, redeemReferralPoints } from '../api'; - -export interface UseReferralHandlersParams { - stats: ReferralStats | null; - /** - * 统计数据更新回调。 - * 在新的实现中,由上层负责重新拉取 Dashboard,因此不再直接传递 stats。 - */ - onStatsUpdate: () => void; -} - -export interface UseReferralHandlersReturn { - // 兑换对话框 - redeemDialogOpen: boolean; - setRedeemDialogOpen: (open: boolean) => void; - redeemPoints: string; - setRedeemPoints: (points: string) => void; - redeemType: RedeemType; - setRedeemType: (type: RedeemType) => void; - - // 事件处理 - handleSwitchLink: (linkId: string) => Promise; - handleCopy: (text: string, label: string) => Promise; - handleRedeem: () => Promise; -} - -export function useReferralHandlers(params: UseReferralHandlersParams): UseReferralHandlersReturn { - const { stats, onStatsUpdate } = params; - const { t } = useTranslation('referral'); - - const [redeemDialogOpen, setRedeemDialogOpen] = useState(false); - const [redeemPoints, setRedeemPoints] = useState(''); - const [redeemType, setRedeemType] = useState('quota'); - - const handleSwitchLink = useCallback( - async (linkId: string) => { - try { - await switchReferralLink({ link_uuid: linkId }); - // 切换成功后由上层触发 Dashboard 刷新 - onStatsUpdate(); - } catch { - toast.error('切换推广链接失败'); - } - }, - [onStatsUpdate] - ); - - const handleCopy = useCallback(async (text: string, label: string) => { - if (!text) { - toast.error('复制失败:链接为空'); - return; - } - - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(text); - } else { - // 兼容性回退:使用隐藏 textarea + execCommand - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.left = '-9999px'; - textarea.style.top = '0'; - textarea.style.opacity = '0'; - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - const ok = document.execCommand('copy'); - document.body.removeChild(textarea); - - if (!ok) { - throw new Error('execCommand copy failed'); - } - } - - toast.success(`已复制${label}`); - } catch (e) { - // 控制台打印错误原因,方便排查 - // eslint-disable-next-line no-console - console.error('Copy referral link failed:', e); - toast.error('复制失败'); - } - }, []); - - const handleRedeem = useCallback(async () => { - const points = parseInt(redeemPoints, 10); - if (!points || points <= 0) { - toast.warning('请输入有效的点数'); - return; - } - if (!stats || stats.availablePoints < points) { - toast.warning(t('redeem.insufficientPoints')); - return; - } - try { - await redeemReferralPoints({ points, type: redeemType }); - toast.success(t('redeem.success')); - setRedeemDialogOpen(false); - setRedeemPoints(''); - // 成功后交给上层刷新 Dashboard / 统计 - onStatsUpdate(); - } catch { - toast.error(t('redeem.error')); - } - }, [redeemPoints, redeemType, stats, t, onStatsUpdate]); - - return { - redeemDialogOpen, - setRedeemDialogOpen, - redeemPoints, - setRedeemPoints, - redeemType, - setRedeemType, - handleSwitchLink, - handleCopy, - handleRedeem, - }; -} diff --git a/plugins/pages/referral-program/src/hooks/use-referral-rewards.ts b/plugins/pages/referral-program/src/hooks/use-referral-rewards.ts deleted file mode 100644 index 5979500b..00000000 --- a/plugins/pages/referral-program/src/hooks/use-referral-rewards.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import type { ReferralReward, RewardStatusFilter } from '../types'; -import { listReferralRewards } from '../api'; - -export interface UseReferralRewardsParams { - page: number; - statusFilter: RewardStatusFilter; - typeFilter: string; - searchQuery: string; - enabled?: boolean; -} - -export interface UseReferralRewardsReturn { - rewards: ReferralReward[]; - totalPages: number; - loading: boolean; - error: string | null; - refresh: () => Promise; -} - -export function useReferralRewards(params: UseReferralRewardsParams): UseReferralRewardsReturn { - const { page, statusFilter, typeFilter, searchQuery, enabled = true } = params; - const [rewards, setRewards] = useState([]); - const [totalPages, setTotalPages] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchRewards = useCallback(async () => { - if (!enabled) return; - setLoading(true); - setError(null); - try { - const data = await listReferralRewards({ - page, - keyword: searchQuery || null, - reward_type: typeFilter && typeFilter !== 'all' ? typeFilter : null, - status: statusFilter === 'all' ? null : statusFilter, - }); - - setRewards(data.items ?? []); - const totalPagesCalc = - data.page_size > 0 ? Math.max(1, Math.ceil(data.total / data.page_size)) : 1; - setTotalPages(totalPagesCalc); - } catch (e) { - console.error('Failed to fetch rewards:', e); - setRewards([]); - setTotalPages(1); - setError(e instanceof Error ? e.message : '未知错误'); - } finally { - setLoading(false); - } - }, [page, searchQuery, statusFilter, typeFilter, enabled]); - - useEffect(() => { - void fetchRewards(); - }, [fetchRewards]); - - return { - rewards, - totalPages, - loading, - error, - refresh: fetchRewards, - }; -} diff --git a/plugins/pages/referral-program/src/hooks/use-referral-stats.ts b/plugins/pages/referral-program/src/hooks/use-referral-stats.ts deleted file mode 100644 index f9054049..00000000 --- a/plugins/pages/referral-program/src/hooks/use-referral-stats.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import type { ReferralStats } from '../types'; -import { API_ENDPOINTS } from '../constants'; - -export interface UseReferralStatsReturn { - stats: ReferralStats | null; - loading: boolean; - error: string | null; - refresh: () => Promise; -} - -export function useReferralStats(): UseReferralStatsReturn { - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchStats = useCallback(async () => { - setLoading(true); - setError(null); - try { - const res = await fetch(API_ENDPOINTS.STATS); - if (!res.ok) throw new Error(`请求失败: ${res.status}`); - const json = (await res.json()) as { data: ReferralStats }; - setStats(json.data); - } catch (e) { - setError(e instanceof Error ? e.message : '未知错误'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void fetchStats(); - }, [fetchStats]); - - return { - stats, - loading, - error, - refresh: fetchStats, - }; -} diff --git a/plugins/pages/referral-program/src/hooks/use-referral-tab-handlers.ts b/plugins/pages/referral-program/src/hooks/use-referral-tab-handlers.ts deleted file mode 100644 index 1e3bf298..00000000 --- a/plugins/pages/referral-program/src/hooks/use-referral-tab-handlers.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { useCallback } from 'react'; -import type { RewardStatusFilter, UserStatusFilter } from '../types'; -import type { UseReferralFiltersReturn } from './use-referral-filters'; - -export interface UseReferralTabHandlersParams { - filters: UseReferralFiltersReturn; - rewardTotalPages: number; - userTotalPages: number; -} - -export interface UseReferralTabHandlersReturn { - // 奖励相关处理函数 - handleRewardSearchChange: (query: string) => void; - handleRewardStatusFilterChange: (filter: RewardStatusFilter) => void; - handleRewardTypeFilterChange: (filter: string) => void; - handleRewardPageChange: (page: number) => void; - - // 用户相关处理函数 - handleUserSearchChange: (query: string) => void; - handleUserStatusFilterChange: (filter: UserStatusFilter) => void; - handleUserPageChange: (page: number) => void; -} - -/** - * Tab 相关事件处理 Hook - * 整合所有 Tab 相关的事件处理函数 - */ -export function useReferralTabHandlers( - params: UseReferralTabHandlersParams -): UseReferralTabHandlersReturn { - const { filters, rewardTotalPages, userTotalPages } = params; - - // 奖励相关处理函数 - const handleRewardSearchChange = useCallback( - (query: string) => { - filters.setRewardSearchQuery(query); - }, - [filters] - ); - - const handleRewardStatusFilterChange = useCallback( - (filter: RewardStatusFilter) => { - filters.setRewardStatusFilter(filter); - }, - [filters] - ); - - const handleRewardTypeFilterChange = useCallback( - (filter: string) => { - filters.setRewardTypeFilter(filter); - }, - [filters] - ); - - const handleRewardPageChange = useCallback( - (page: number) => { - if (page < 1 || page > Math.max(rewardTotalPages, 1)) return; - filters.setRewardPage(page); - }, - [filters, rewardTotalPages] - ); - - // 用户相关处理函数 - const handleUserSearchChange = useCallback( - (query: string) => { - filters.setUserSearchQuery(query); - }, - [filters] - ); - - const handleUserStatusFilterChange = useCallback( - (filter: UserStatusFilter) => { - filters.setUserStatusFilter(filter); - }, - [filters] - ); - - const handleUserPageChange = useCallback( - (page: number) => { - if (page < 1 || page > Math.max(userTotalPages, 1)) return; - filters.setUserPage(page); - }, - [filters, userTotalPages] - ); - - return { - handleRewardSearchChange, - handleRewardStatusFilterChange, - handleRewardTypeFilterChange, - handleRewardPageChange, - handleUserSearchChange, - handleUserStatusFilterChange, - handleUserPageChange, - }; -} diff --git a/plugins/pages/referral-program/src/hooks/use-referral-tabs.ts b/plugins/pages/referral-program/src/hooks/use-referral-tabs.ts deleted file mode 100644 index d8e8b48e..00000000 --- a/plugins/pages/referral-program/src/hooks/use-referral-tabs.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useState } from 'react'; -import type { ReferralTab } from '../types'; - -export interface UseReferralTabsReturn { - activeTab: ReferralTab; - setActiveTab: (tab: ReferralTab) => void; -} - -export function useReferralTabs(): UseReferralTabsReturn { - const [activeTab, setActiveTab] = useState('overview'); - - return { - activeTab, - setActiveTab, - }; -} diff --git a/plugins/pages/referral-program/src/hooks/use-referred-users.ts b/plugins/pages/referral-program/src/hooks/use-referred-users.ts deleted file mode 100644 index 5eac2d9a..00000000 --- a/plugins/pages/referral-program/src/hooks/use-referred-users.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import type { ReferredUser, UserStatusFilter } from '../types'; -import { listReferredUsers } from '../api'; - -export interface UseReferredUsersParams { - page: number; - statusFilter: UserStatusFilter; - searchQuery: string; - enabled?: boolean; -} - -export interface UseReferredUsersReturn { - users: ReferredUser[]; - totalPages: number; - loading: boolean; - error: string | null; - refresh: () => Promise; -} - -export function useReferredUsers(params: UseReferredUsersParams): UseReferredUsersReturn { - const { page, statusFilter, searchQuery, enabled = true } = params; - const [users, setUsers] = useState([]); - const [totalPages, setTotalPages] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchReferredUsers = useCallback(async () => { - if (!enabled) return; - setLoading(true); - setError(null); - try { - const data = await listReferredUsers({ - page, - keyword: searchQuery || null, - status: statusFilter === 'all' ? null : statusFilter, - }); - - setUsers(data.items ?? []); - const totalPagesCalc = - data.page_size > 0 ? Math.max(1, Math.ceil(data.total / data.page_size)) : 1; - setTotalPages(totalPagesCalc); - } catch (e) { - console.error('Failed to fetch referred users:', e); - setUsers([]); - setTotalPages(1); - setError(e instanceof Error ? e.message : '未知错误'); - } finally { - setLoading(false); - } - }, [page, searchQuery, statusFilter, enabled]); - - useEffect(() => { - void fetchReferredUsers(); - }, [fetchReferredUsers]); - - return { - users, - totalPages, - loading, - error, - refresh: fetchReferredUsers, - }; -} diff --git a/plugins/pages/referral-program/src/i18n/resources.ts b/plugins/pages/referral-program/src/i18n/resources.ts deleted file mode 100644 index e7bbc96f..00000000 --- a/plugins/pages/referral-program/src/i18n/resources.ts +++ /dev/null @@ -1,248 +0,0 @@ -export const referralResources = { - 'zh-CN': { - title: '推广计划', - subtitle: '邀请好友,获得更多奖励', - loading: '加载中...', - error: '加载失败:{{error}}', - tabs: { - overview: '概览', - rewards: '奖励明细', - users: '被邀请用户', - }, - overview: { - withdrawableBalance: '可兑换奖励', - withdrawableBalanceDesc: '可兑换为功能配额或使用时长', - withdrawableBalanceMotivation: - '邀请好友使用 Simprint,好友注册、订阅或消费时,您将获得相应奖励点数。奖励点数可用于兑换功能配额或延长使用时长。', - pendingRewards: '待审核奖励', - applyRedeem: '申请兑换', - stats: { - linkClicks: '链接点击次数', - registeredUsers: '名下用户', - paidUsers: '充值用户', - underReview: '审核中', - last30DaysConsumption: '近30天名下用户消费金额', - totalEarned: '已获推广奖励', - }, - selectLink: '选择推广链接', - linkFallback: '链接 {{index}}', - currentLink: '当前推广链接', - copyLink: '复制链接', - copyInvitationCode: '复制邀请码', - createCustom: '创建自定义链接/邀请码', - rewardRules: '受邀用户消费时', - youWillGet: '您将获得', - theyWillGet: '他们得到', - consumptionAmount: '消费金额', - consumptionDiscount: '消费优惠', - dataStats: '数据统计', - promoBanners: '推广宣传图', - promoBannersDesc: '建站用户专用', - selectSize: '选择尺寸', - htmlCode: 'HTML 代码', - copyCode: '复制代码', - linkLocked: '未解锁', - linkUnlockCondition: '需邀请{{count}}人解锁', - defaultUnlockCondition: '达到指定邀请人数后自动解锁此推广链接', - }, - rewards: { - title: '奖励明细', - noData: '暂无奖励记录', - table: { - index: '序号', - date: '日期', - type: '类型', - description: '描述', - points: '奖励点数', - status: '状态', - user: '被邀请用户', - }, - types: { - registration: '注册奖励', - subscription: '订阅奖励', - consumption: '消费奖励', - }, - status: { - pending: '审核中', - approved: '已通过', - rejected: '已拒绝', - }, - filters: { - allStatuses: '全部状态', - allTypes: '全部类型', - }, - searchPlaceholder: '搜索奖励描述...', - refresh: '刷新', - pagination: { - pageInfo: '第 {{current}} 页,共 {{total}} 页', - previous: '上一页', - next: '下一页', - }, - }, - users: { - title: '被邀请用户', - noData: '暂无被邀请用户', - table: { - index: '序号', - email: '邮箱', - registeredAt: '注册时间', - status: '状态', - totalConsumption: '总消费', - last30DaysConsumption: '近30天消费', - link: '推广链接', - }, - status: { - registered: '已注册', - paid: '已付费', - active: '活跃', - }, - filters: { - allStatuses: '全部状态', - }, - searchPlaceholder: '搜索被邀请用户邮箱...', - refresh: '刷新', - pagination: { - pageInfo: '第 {{current}} 页,共 {{total}} 页', - previous: '上一页', - next: '下一页', - }, - }, - redeem: { - title: '申请兑换', - description: '将奖励点数兑换为功能配额或使用时长', - points: '兑换点数', - type: '兑换类型', - types: { - quota: '环境配额', - feature: '高级功能', - duration: '使用时长', - }, - success: '兑换成功', - error: '兑换失败', - insufficientPoints: '点数不足', - }, - }, - 'en-US': { - title: 'Referral Program', - subtitle: 'Invite friends and earn rewards', - loading: 'Loading...', - error: 'Failed to load: {{error}}', - tabs: { - overview: 'Overview', - rewards: 'Reward Details', - users: 'Referred Users', - }, - overview: { - withdrawableBalance: 'Redeemable Rewards', - withdrawableBalanceDesc: 'Can be redeemed for feature quotas or usage duration', - withdrawableBalanceMotivation: - 'Invite friends to use Simprint. When they register, subscribe, or make purchases, you will earn reward points. Reward points can be redeemed for feature quotas or extended usage duration.', - pendingRewards: 'Pending Rewards', - applyRedeem: 'Apply for Redemption', - stats: { - linkClicks: 'Link Click Count', - registeredUsers: 'Referred Users', - paidUsers: 'Paid Users', - underReview: 'Under Review', - last30DaysConsumption: 'Referred User Consumption (Last 30 Days)', - totalEarned: 'Promotion Rewards Earned', - }, - selectLink: 'Select Promotion Link', - linkFallback: 'Link {{index}}', - currentLink: 'Current Promotion Link', - copyLink: 'Copy Link', - copyInvitationCode: 'Copy Invitation Code', - createCustom: 'Create Custom Link/Invitation Code', - rewardRules: 'When invited users consume', - youWillGet: 'You will get', - theyWillGet: 'They will get', - consumptionAmount: 'consumption amount', - consumptionDiscount: 'consumption discount', - dataStats: 'Data Statistics', - promoBanners: 'Promotional Images', - promoBannersDesc: 'For website users', - selectSize: 'Select Size', - htmlCode: 'HTML Code', - copyCode: 'Copy Code', - linkLocked: 'Locked', - linkUnlockCondition: 'Requires {{count}} referrals to unlock', - defaultUnlockCondition: 'This promotion link will be unlocked automatically after you reach the required number of referrals', - }, - rewards: { - title: 'Reward Details', - noData: 'No reward records', - table: { - index: 'Index', - date: 'Date', - type: 'Type', - description: 'Description', - points: 'Points', - status: 'Status', - user: 'Referred User', - }, - types: { - registration: 'Registration Reward', - subscription: 'Subscription Reward', - consumption: 'Consumption Reward', - }, - status: { - pending: 'Pending', - approved: 'Approved', - rejected: 'Rejected', - }, - filters: { - allStatuses: 'All Statuses', - allTypes: 'All Types', - }, - searchPlaceholder: 'Search reward descriptions...', - refresh: 'Refresh', - pagination: { - pageInfo: 'Page {{current}} of {{total}}', - previous: 'Previous', - next: 'Next', - }, - }, - users: { - title: 'Referred Users', - noData: 'No referred users', - table: { - index: 'Index', - email: 'Email', - registeredAt: 'Registered At', - status: 'Status', - totalConsumption: 'Total Consumption', - last30DaysConsumption: 'Last 30 Days Consumption', - link: 'Promotion Link', - }, - status: { - registered: 'Registered', - paid: 'Paid', - active: 'Active', - }, - filters: { - allStatuses: 'All Statuses', - }, - searchPlaceholder: 'Search referred users...', - refresh: 'Refresh', - pagination: { - pageInfo: 'Page {{current}} of {{total}}', - previous: 'Previous', - next: 'Next', - }, - }, - redeem: { - title: 'Apply for Redemption', - description: 'Redeem reward points for feature quotas or usage duration', - points: 'Redeem Points', - type: 'Redemption Type', - types: { - quota: 'Environment Quota', - feature: 'Premium Features', - duration: 'Usage Duration', - }, - success: 'Redemption successful', - error: 'Redemption failed', - insufficientPoints: 'Insufficient points', - }, - }, -} as const; diff --git a/plugins/pages/referral-program/src/index.tsx b/plugins/pages/referral-program/src/index.tsx deleted file mode 100644 index aa395f1a..00000000 --- a/plugins/pages/referral-program/src/index.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { extensionRegistry } from '@slotkitjs/core'; -import { useTranslation } from 'react-i18next'; -import { referralResources } from './i18n/resources'; -import { ReferralTabs } from './components/referral-tabs'; -import { OverviewTab } from './components/overview-tab'; -import { RewardsTab } from './components/rewards-tab'; -import { UsersTab } from './components/users-tab'; -import { RedeemDialog } from './components/redeem-dialog'; -import { ReferralPageSkeleton } from './components/referral-page-skeleton'; -import { useReferralDashboard } from './hooks/use-referral-dashboard'; -import { useReferralRewards } from './hooks/use-referral-rewards'; -import { useReferredUsers } from './hooks/use-referred-users'; -import { useReferralTabs } from './hooks/use-referral-tabs'; -import { useReferralFilters } from './hooks/use-referral-filters'; -import { useReferralHandlers } from './hooks/use-referral-handlers'; -import { useReferralComputed } from './hooks/use-referral-computed'; -import { useReferralTabHandlers } from './hooks/use-referral-tab-handlers'; - -const ReferralProgramPage: React.FC = () => { - const { t } = useTranslation('referral'); - - // Tab 管理 - const { activeTab, setActiveTab } = useReferralTabs(); - - // 数据获取:使用后端聚合的看板接口 - const { - dashboard, - loading, - error, - refresh: refreshDashboard, - } = useReferralDashboard(); - - // 过滤状态管理 - const filters = useReferralFilters(); - - // 奖励数据(仅在 rewards tab 激活时获取) - const shouldFetchRewards = activeTab === 'rewards'; - const rewardsData = useReferralRewards({ - page: filters.rewardPage, - statusFilter: filters.rewardStatusFilter, - typeFilter: filters.rewardTypeFilter, - searchQuery: filters.rewardSearchQuery, - enabled: shouldFetchRewards, - }); - - // 被邀请用户数据(仅在 users tab 激活时获取) - const shouldFetchUsers = activeTab === 'users'; - const usersData = useReferredUsers({ - page: filters.userPage, - statusFilter: filters.userStatusFilter, - searchQuery: filters.userSearchQuery, - enabled: shouldFetchUsers, - }); - - // 事件处理 - const handlers = useReferralHandlers({ - stats: dashboard?.stats ?? null, - // 交给 Dashboard Hook 统一刷新 - onStatsUpdate: () => { - void refreshDashboard(); - }, - }); - - // 计算衍生数据 - const { currentLink, rewardTypes } = useReferralComputed({ - stats: dashboard?.stats ?? null, - rewards: rewardsData.rewards, - }); - - // Tab 相关事件处理 - const tabHandlers = useReferralTabHandlers({ - filters, - rewardTotalPages: rewardsData.totalPages, - userTotalPages: usersData.totalPages, - }); - - return ( -
- {/* 顶部区域 - 包含 Tab 导航 */} - - -
- {error && ( -
-
{t('error', { error })}
-
- )} - {!error && ( -
- {/* 概览标签页 */} - {activeTab === 'overview' && - (loading || !dashboard?.stats ? ( - - ) : ( - handlers.setRedeemDialogOpen(true)} - onSwitchLink={handlers.handleSwitchLink} - onCopy={handlers.handleCopy} - /> - ))} - - {/* 奖励明细标签页 */} - {activeTab === 'rewards' && ( - - )} - - {/* 被邀请用户标签页 */} - {activeTab === 'users' && ( - - )} -
- )} -
- - {/* 兑换对话框 */} - -
- ); -}; - -// 在模块加载时贡献路由 -try { - extensionRegistry.contribute('routes', { - contributorId: 'referral-program', - value: { - path: '/referral', - Component: ReferralProgramPage, - }, - priority: 10, - }); - console.log('[referral-program] Route contributed at module load: /referral'); -} catch (error) { - console.warn('[referral-program] Failed to contribute route at module load:', error); -} - -try { - extensionRegistry.contribute('i18n:resources', { - contributorId: 'referral-program', - value: { - namespace: 'referral', - resources: referralResources, - }, - priority: 10, - }); -} catch (error) { - console.warn('[referral-program] Failed to contribute i18n resources:', error); -} - -const referralProgramPlugin = { - id: 'referral-program', - name: 'Referral Program', - version: '1.0.0', - component: ReferralProgramPage, - slots: [], -}; - -export default referralProgramPlugin; diff --git a/plugins/pages/referral-program/src/types/index.ts b/plugins/pages/referral-program/src/types/index.ts deleted file mode 100644 index e30602cc..00000000 --- a/plugins/pages/referral-program/src/types/index.ts +++ /dev/null @@ -1,56 +0,0 @@ -export interface ReferralLink { - id: string; - name: string; - code: string; - url: string; - unlocked: boolean; - rewardRate: number; - discountRate: number; - description?: string; -} - -export interface ReferralStats { - code: string; - currentLinkId: string; - availablePoints: number; - pendingPoints: number; - totalEarnedPoints: number; - linkClicks: number; - registeredUsers: number; - paidUsers: number; - last30DaysConsumption: number; - links: ReferralLink[]; -} - -export interface ReferralReward { - id: string; - createdAt: string; - type: 'registration' | 'subscription' | 'consumption'; - description: string; - points: number; - status: 'pending' | 'approved' | 'rejected'; - referredUser?: string; - linkId: string; -} - -export interface ReferredUser { - id: string; - email: string; - registeredAt: string; - status: 'registered' | 'paid' | 'active'; - totalConsumption: number; - last30DaysConsumption: number; - linkId: string; -} - -export interface PromoBannerSize { - id: string; - width: number; - height: number; - label: string; -} - -export type RewardStatusFilter = 'all' | 'pending' | 'approved' | 'rejected'; -export type UserStatusFilter = 'all' | 'registered' | 'paid' | 'active'; -export type RedeemType = 'quota' | 'feature' | 'duration'; -export type ReferralTab = 'overview' | 'rewards' | 'users'; diff --git a/plugins/pages/referral-program/tsconfig.json b/plugins/pages/referral-program/tsconfig.json deleted file mode 100644 index f5b67230..00000000 --- a/plugins/pages/referral-program/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "jsx": "react-jsx" - }, - "include": ["src/**/*"] -} diff --git a/plugins/pages/register/src/api/index.ts b/plugins/pages/register/src/api/index.ts index 4ff80660..69319cb1 100644 --- a/plugins/pages/register/src/api/index.ts +++ b/plugins/pages/register/src/api/index.ts @@ -1,89 +1,10 @@ -/** - * 注册 API 服务 - */ -import { invoke } from '@/lib/tauri'; -import { post, isSuccess } from '@/lib/request'; -import type { - SendCodeRequest, - RegisterPayload, - RegisterResponseData, - CodeType, -} from './index.types'; - -// 导出类型 -export * from './index.types'; - -// Tauri 命令配置 -export const TAURI_COMMANDS = { - REGISTER: 'register', -} as const; - -// HTTP API 端点配置 -export const API_ENDPOINTS = { - SEND_CODE: 'users/register-send-code', -} as const; - -/** - * API 响应格式 - */ -interface ApiResponse { - code: number; - message: string; - data?: T; -} - -/** - * 发送验证码(HTTP API) - */ -export async function sendVerificationCode( - email: string, - type: CodeType = 'register' -): Promise { - const requestData: SendCodeRequest = { - email, - type, - }; - - const result = await post(API_ENDPOINTS.SEND_CODE, requestData); - - if (!isSuccess(result)) { - throw new Error(result.message || '发送验证码失败'); - } -} - -/** - * 用户注册(Tauri 命令) - * - * 注意:注册必须通过 Tauri 命令进行,因为: - * 1. 需要生成 RSA 公钥 - * 2. 需要获取机器信息 - * 3. 需要保存 access_token 和 refresh_token - * - * @param email - 邮箱 - * @param password - 密码 - * @param code - 验证码 - * @param referralCode - 邀请码(可选) - */ -export async function register( - email: string, - password: string, - code: string, - referralCode?: string -): Promise { - const payload: RegisterPayload = { - email, - password, - code, - ...(referralCode && { referral_code: referralCode }), - }; - - const result = (await invoke(TAURI_COMMANDS.REGISTER, { - payload, - })) as ApiResponse; - - if (result.code !== 1) { - throw new Error(result.message || '注册失败'); - } - - return result.data!; -} +import { invoke } from '@/lib/tauri'; +import type { CreateLocalUserPayload, LocalUserProfile } from './index.types'; + +export * from './index.types'; + +export async function createLocalUser( + payload: CreateLocalUserPayload +): Promise { + return invoke('create_local_user', { payload }); +} diff --git a/plugins/pages/register/src/api/index.types.ts b/plugins/pages/register/src/api/index.types.ts index 09dffcb0..bb5e7161 100644 --- a/plugins/pages/register/src/api/index.types.ts +++ b/plugins/pages/register/src/api/index.types.ts @@ -1,71 +1,14 @@ -/** - * 注册 API 类型定义 - */ - -/** - * 验证码类型 - */ -export type CodeType = 'register' | 'reset_password'; - -/** - * 发送验证码请求 - */ -export interface SendCodeRequest { - email: string; - type: CodeType; -} - -/** - * 注册请求载荷(发送给 Tauri 命令) - */ -export interface RegisterPayload { - email: string; - password: string; - code: string; - referral_code?: string; // 邀请码(可选) -} - -/** - * 团队信息 - */ -export interface TeamInfoResponse { - id: number; - uuid: string; - name: string; - description?: string; - owner_uuid: string; - avatar_hash?: string; - max_members: number; - max_environments: number; - max_proxies: number; - default_proxy_uuid?: string; - status: string; - created_at: string; - updated_at: string; - deleted_at?: string; -} - -/** - * 用户信息响应 - */ -export interface UserInfoResponse { - uuid: string; - id: string; - nickname?: string; - email?: string; - phone?: string; - avatar_hash?: string; - status?: string; - created_at?: string; - updated_at?: string; - current_team?: TeamInfoResponse; -} - -/** - * 注册响应数据 - */ -export interface RegisterResponseData { - access_token: string; - refresh_token: string; - user_info?: UserInfoResponse; -} +export interface CreateLocalUserPayload { + nickname: string; + avatar: string; + password?: string | null; +} + +export interface LocalUserProfile { + uuid: string; + nickname: string; + avatar: string; + hasPassword: boolean; + currentWorkspaceUuid?: string | null; + currentTeamUuid?: string | null; +} diff --git a/plugins/pages/register/src/components/local-nickname-input.tsx b/plugins/pages/register/src/components/local-nickname-input.tsx new file mode 100644 index 00000000..f53242a4 --- /dev/null +++ b/plugins/pages/register/src/components/local-nickname-input.tsx @@ -0,0 +1,81 @@ +import type { MouseEvent } from 'react'; +import { Dices } from 'lucide-react'; + +import { TextareaInput } from '@/components/textarea-input'; + +interface LocalNicknameInputProps { + id: string; + value: string; + avatar: string; + placeholder: string; + randomAvatarLabel: string; + randomNicknameLabel: string; + autoFocus?: boolean; + invalid?: boolean; + onChange: (value: string) => void; + onRandomizeAvatar: () => void; + onRandomizeNickname: () => void; +} + +export function LocalNicknameInput({ + id, + value, + avatar, + placeholder, + randomAvatarLabel, + randomNicknameLabel, + autoFocus, + invalid, + onChange, + onRandomizeAvatar, + onRandomizeNickname, +}: LocalNicknameInputProps) { + const keepInputFocus = (event: MouseEvent) => { + event.preventDefault(); + }; + + return ( +
+ + + onChange(event.target.value)} + /> + + +
+ ); +} diff --git a/plugins/pages/register/src/components/register-form.tsx b/plugins/pages/register/src/components/register-form.tsx index a5b01d93..bd634fd9 100644 --- a/plugins/pages/register/src/components/register-form.tsx +++ b/plugins/pages/register/src/components/register-form.tsx @@ -1,238 +1,166 @@ -import { type FormEvent } from 'react'; -import { useNavigate } from 'react-router'; -import { Mail, Lock, ArrowLeft, UserPlus, Send, Key } from 'lucide-react'; -import { Input } from '@/components/ui/input'; -import { TextareaInput } from '@/components/textarea-input'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { useTranslation } from 'react-i18next'; -import { useRegisterForm } from '../hooks/use-register-form'; -import { useRegisterValidation } from '../hooks/use-register-validation'; -import { useRegisterCode } from '../hooks/use-register-code'; -import { useRegister } from '../hooks/use-register'; -import { CODE_MAX_LENGTH } from '../constants'; - -export const RegisterForm: React.FC = () => { - const navigate = useNavigate(); - const { t } = useTranslation('auth'); - - // 表单状态管理 - const { formData, setEmail, setPassword, setConfirmPassword, setCode } = useRegisterForm(); - - // 表单验证 - const { errors, validateForm, validateEmail, clearErrors, setEmailError, setCodeError } = - useRegisterValidation(); - - // 验证码管理 - const { codeSent, countdown, sendCode, resetCode } = useRegisterCode(); - - // 注册操作 - const { handleRegister } = useRegister(); - - // 处理发送验证码 - const handleSendCode = async () => { - clearErrors(); - - // 验证邮箱 - if (!validateEmail(formData.email)) { - return; - } - - try { - await sendCode(formData.email); - } catch (error: any) { - setEmailError(error?.message || error?.toString() || '发送验证码失败'); - } - }; - - // 处理邮箱变化 - const handleEmailChange = (value: string) => { - setEmail(value); - clearErrors(); - resetCode(); - setCode(''); - }; - - // 处理表单提交 - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - - // 清除之前的错误 - clearErrors(); - - // 验证表单 - if (!validateForm(formData, codeSent)) { - return; - } - - // 执行注册 - try { - await handleRegister(formData); - } catch (error: any) { - console.error('注册失败:', error); - const errorMessage = error?.message || error?.toString() || '注册失败,请重试'; - setCodeError(errorMessage); - } - }; - - return ( - <> -
-

{t('register.title')}

-

{t('register.subtitle')}

-
- -
-
- -
- - handleEmailChange(e.target.value)} - aria-invalid={!!errors.email} - className="pl-9" - required - /> -
- {errors.email &&

{errors.email}

} -
- -
- - {!codeSent ? ( - // 默认状态:只显示发送验证码按钮 - - ) : ( - // 发送后:显示输入框 -
- {countdown > 0 ? ( - // 倒计时中:只显示输入框 - <> -
- - { - setCode(e.target.value); - clearErrors(); - }} - aria-invalid={!!errors.code} - className="pl-9" - required - maxLength={CODE_MAX_LENGTH} - /> -
- {errors.code &&

{errors.code}

} -

- 验证码已发送,{countdown} 秒后可重新发送 -

- - ) : ( - // 过期后:输入框和发送按钮在同一行 -
-
- - { - setCode(e.target.value); - clearErrors(); - }} - aria-invalid={!!errors.code} - className="pl-9" - required - maxLength={CODE_MAX_LENGTH} - /> -
- -
- )} - {errors.code && countdown === 0 && ( -

{errors.code}

- )} -
- )} -
- -
- -
- - { - setPassword(e.target.value); - clearErrors(); - }} - aria-invalid={!!errors.password} - className="pl-9" - required - minLength={8} - /> -
- {errors.password &&

{errors.password}

} -
- -
- -
- - { - setConfirmPassword(e.target.value); - clearErrors(); - }} - aria-invalid={!!errors.confirmPassword} - className="pl-9" - required - /> -
- {errors.confirmPassword && ( -

{errors.confirmPassword}

- )} -
- -
- - -
-
- - ); -}; +import { useState, type FormEvent } from 'react'; +import { useNavigate } from 'react-router'; +import { ArrowLeft, LoaderCircle, LockKeyhole, UserPlus } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { useAuth } from '../../../../services/store/src'; +import { createLocalUser } from '../api'; +import { LocalNicknameInput } from './local-nickname-input'; + +const AVATARS = ['🙂', '😎', '🦊', '🐼', '🐯', '🐙', '🦉', '🐳', '🌙', '⭐', '🌿', '🚀']; +const CHINESE_NICKNAME_PARTS = { + adjectives: ['安静的', '自由的', '幸运的', '勇敢的', '好奇的', '闪亮的', '悠闲的', '快乐的'], + nouns: ['星河', '旅人', '狐狸', '鲸鱼', '猫头鹰', '月光', '青竹', '火箭'], +}; +const ENGLISH_NICKNAME_PARTS = { + adjectives: ['Quiet', 'Free', 'Lucky', 'Brave', 'Curious', 'Bright', 'Easygoing', 'Happy'], + nouns: ['Voyager', 'Fox', 'Whale', 'Owl', 'Moon', 'Bamboo', 'Rocket', 'Comet'], +}; + +function randomAvatar(current: string): string { + const alternatives = AVATARS.filter((avatar) => avatar !== current); + return alternatives[Math.floor(Math.random() * alternatives.length)] || AVATARS[0]; +} + +function randomNickname(language: string, current: string): string { + const parts = language.toLowerCase().startsWith('zh') + ? CHINESE_NICKNAME_PARTS + : ENGLISH_NICKNAME_PARTS; + const adjective = parts.adjectives[Math.floor(Math.random() * parts.adjectives.length)]; + const noun = parts.nouns[Math.floor(Math.random() * parts.nouns.length)]; + const candidate = `${adjective}${noun}`; + return candidate === current.trim() + ? `${candidate}${Math.floor(Math.random() * 90) + 10}` + : candidate; +} + +export const RegisterForm: React.FC = () => { + const navigate = useNavigate(); + const { t, i18n } = useTranslation('auth'); + const { setUser } = useAuth(); + const [nickname, setNickname] = useState(''); + const [avatar, setAvatar] = useState(() => randomAvatar('')); + const [password, setPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!nickname.trim()) { + setError(t('register.nicknameRequired')); + return; + } + setSubmitting(true); + setError(''); + try { + const user = await createLocalUser({ + nickname: nickname.trim(), + avatar, + password: password || null, + }); + setUser({ + uuid: user.uuid, + id: user.uuid, + nickname: user.nickname, + avatar: user.avatar, + has_password: user.hasPassword, + status: 'active', + current_workspace_uuid: user.currentWorkspaceUuid ?? null, + current_team_uuid: user.currentTeamUuid ?? null, + }); + navigate('/'); + } catch (reason) { + const message = reason instanceof Error ? reason.message : String(reason); + setError(message); + toast.error(message); + } finally { + setSubmitting(false); + } + }; + + return ( + <> +
+
+ {avatar} +
+

{t('register.title')}

+

{t('register.subtitle')}

+
+ +
+
+ + setAvatar(randomAvatar(avatar))} + onRandomizeNickname={() => { + setNickname(randomNickname(i18n.resolvedLanguage ?? i18n.language, nickname)); + setAvatar(randomAvatar(avatar)); + setError(''); + }} + onChange={(value) => { + setNickname(value); + setError(''); + }} + /> +
+ +
+ +
+ + + + { + setPassword(event.target.value); + setError(''); + }} + placeholder={t('register.passwordPlaceholder')} + autoComplete="new-password" + className="pl-10" + /> +
+

{t('register.passwordHint')}

+
+ + {error &&

{error}

} + +
+ + +
+
+ + ); +}; diff --git a/plugins/pages/register/src/constants.ts b/plugins/pages/register/src/constants.ts deleted file mode 100644 index 94632639..00000000 --- a/plugins/pages/register/src/constants.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 邮箱验证正则 - * - 用户名部分:允许大小写字母、数字、点、连字符、下划线 - * - 域名部分:只允许小写字母、数字、连字符 - */ -export const EMAIL_REGEX = - /^[^\s@]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; - -export const MIN_PASSWORD_LENGTH = 8; - -export const CODE_COUNTDOWN_SECONDS = 60; - -export const CODE_MAX_LENGTH = 6; diff --git a/plugins/pages/register/src/hooks/use-register-code.ts b/plugins/pages/register/src/hooks/use-register-code.ts deleted file mode 100644 index 0eab5f55..00000000 --- a/plugins/pages/register/src/hooks/use-register-code.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { toast } from 'sonner'; -import { useLoading } from '../../../../services/store/src'; -import { sendVerificationCode } from '../api'; -import { CODE_COUNTDOWN_SECONDS } from '../constants'; - -interface UseRegisterCodeReturn { - codeSent: boolean; - countdown: number; - sendCode: (email: string) => Promise; - resetCode: () => void; -} - -/** - * 注册验证码管理 Hook - */ -export function useRegisterCode(): UseRegisterCodeReturn { - const { setLoading } = useLoading(); - const [codeSent, setCodeSent] = useState(false); - const [countdown, setCountdown] = useState(0); - - // 倒计时效果 - useEffect(() => { - if (countdown > 0) { - const timer = setTimeout(() => setCountdown(countdown - 1), 1000); - return () => clearTimeout(timer); - } - }, [countdown]); - - const sendCode = useCallback( - async (email: string) => { - setLoading(true); - - try { - await sendVerificationCode(email, 'register'); - setCodeSent(true); - setCountdown(CODE_COUNTDOWN_SECONDS); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : '发送验证码失败'; - toast.error(message); - throw error; - } finally { - setLoading(false); - } - }, - [setLoading] - ); - - const resetCode = useCallback(() => { - setCodeSent(false); - setCountdown(0); - }, []); - - return { - codeSent, - countdown, - sendCode, - resetCode, - }; -} diff --git a/plugins/pages/register/src/hooks/use-register-form.ts b/plugins/pages/register/src/hooks/use-register-form.ts deleted file mode 100644 index 25d57204..00000000 --- a/plugins/pages/register/src/hooks/use-register-form.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { RegisterFormData } from '../types'; - -interface UseRegisterFormReturn { - formData: RegisterFormData; - setEmail: (email: string) => void; - setPassword: (password: string) => void; - setConfirmPassword: (confirmPassword: string) => void; - setCode: (code: string) => void; - resetForm: () => void; -} - -/** - * 注册表单状态管理 Hook - */ -export function useRegisterForm(): UseRegisterFormReturn { - const [formData, setFormData] = useState({ - email: '', - password: '', - confirmPassword: '', - code: '', - }); - - const setEmail = useCallback((email: string) => { - setFormData((prev) => ({ ...prev, email })); - }, []); - - const setPassword = useCallback((password: string) => { - setFormData((prev) => ({ ...prev, password })); - }, []); - - const setConfirmPassword = useCallback((confirmPassword: string) => { - setFormData((prev) => ({ ...prev, confirmPassword })); - }, []); - - const setCode = useCallback((code: string) => { - setFormData((prev) => ({ ...prev, code })); - }, []); - - const resetForm = useCallback(() => { - setFormData({ - email: '', - password: '', - confirmPassword: '', - code: '', - }); - }, []); - - return { - formData, - setEmail, - setPassword, - setConfirmPassword, - setCode, - resetForm, - }; -} diff --git a/plugins/pages/register/src/hooks/use-register-validation.ts b/plugins/pages/register/src/hooks/use-register-validation.ts deleted file mode 100644 index f0ddb073..00000000 --- a/plugins/pages/register/src/hooks/use-register-validation.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { EMAIL_REGEX, MIN_PASSWORD_LENGTH } from '../constants'; - -interface ValidationErrors { - email: string; - password: string; - confirmPassword: string; - code: string; -} - -interface UseRegisterValidationReturn { - errors: ValidationErrors; - validateEmail: (email: string) => boolean; - validatePassword: (password: string) => boolean; - validateConfirmPassword: (password: string, confirmPassword: string) => boolean; - validateCode: (code: string, codeSent: boolean) => boolean; - validateForm: ( - formData: { email: string; password: string; confirmPassword: string; code: string }, - codeSent: boolean - ) => boolean; - clearErrors: () => void; - setEmailError: (error: string) => void; - setPasswordError: (error: string) => void; - setConfirmPasswordError: (error: string) => void; - setCodeError: (error: string) => void; -} - -/** - * 注册表单验证 Hook - */ -export function useRegisterValidation(): UseRegisterValidationReturn { - useTranslation('auth'); // 注册 i18n namespace - const [errors, setErrors] = useState({ - email: '', - password: '', - confirmPassword: '', - code: '', - }); - - const validateEmail = useCallback((email: string): boolean => { - if (!email) { - setErrors((prev) => ({ ...prev, email: '请输入邮箱地址' })); - return false; - } - - if (!EMAIL_REGEX.test(email)) { - setErrors((prev) => ({ ...prev, email: '请输入有效的邮箱地址' })); - return false; - } - - setErrors((prev) => ({ ...prev, email: '' })); - return true; - }, []); - - const validatePassword = useCallback((password: string): boolean => { - if (!password) { - setErrors((prev) => ({ ...prev, password: '请输入密码' })); - return false; - } - - if (password.length < MIN_PASSWORD_LENGTH) { - setErrors((prev) => ({ ...prev, password: `密码至少需要${MIN_PASSWORD_LENGTH}个字符` })); - return false; - } - - setErrors((prev) => ({ ...prev, password: '' })); - return true; - }, []); - - const validateConfirmPassword = useCallback( - (password: string, confirmPassword: string): boolean => { - if (!confirmPassword) { - setErrors((prev) => ({ ...prev, confirmPassword: '请再次输入密码' })); - return false; - } - - if (password !== confirmPassword) { - setErrors((prev) => ({ ...prev, confirmPassword: '两次输入的密码不一致' })); - return false; - } - - setErrors((prev) => ({ ...prev, confirmPassword: '' })); - return true; - }, - [] - ); - - const validateCode = useCallback((code: string, codeSent: boolean): boolean => { - if (!codeSent) { - setErrors((prev) => ({ ...prev, code: '请先发送验证码' })); - return false; - } - - if (!code) { - setErrors((prev) => ({ ...prev, code: '请输入验证码' })); - return false; - } - - setErrors((prev) => ({ ...prev, code: '' })); - return true; - }, []); - - const validateForm = useCallback( - ( - formData: { email: string; password: string; confirmPassword: string; code: string }, - codeSent: boolean - ): boolean => { - const isEmailValid = validateEmail(formData.email); - const isPasswordValid = validatePassword(formData.password); - const isConfirmPasswordValid = validateConfirmPassword( - formData.password, - formData.confirmPassword - ); - const isCodeValid = validateCode(formData.code, codeSent); - return isEmailValid && isPasswordValid && isConfirmPasswordValid && isCodeValid; - }, - [validateEmail, validatePassword, validateConfirmPassword, validateCode] - ); - - const clearErrors = useCallback(() => { - setErrors({ - email: '', - password: '', - confirmPassword: '', - code: '', - }); - }, []); - - const setEmailError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, email: error })); - }, []); - - const setPasswordError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, password: error })); - }, []); - - const setConfirmPasswordError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, confirmPassword: error })); - }, []); - - const setCodeError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, code: error })); - }, []); - - return { - errors, - validateEmail, - validatePassword, - validateConfirmPassword, - validateCode, - validateForm, - clearErrors, - setEmailError, - setPasswordError, - setConfirmPasswordError, - setCodeError, - }; -} diff --git a/plugins/pages/register/src/hooks/use-register.ts b/plugins/pages/register/src/hooks/use-register.ts deleted file mode 100644 index dd1e68f2..00000000 --- a/plugins/pages/register/src/hooks/use-register.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { useCallback } from 'react'; -import { useNavigate } from 'react-router'; -import { toast } from 'sonner'; -import { useAuth, useLoading } from '../../../../services/store/src'; -import { useTranslation } from 'react-i18next'; -import type { RegisterFormData } from '../types'; -import { register } from '../api'; - -interface UseRegisterReturn { - handleRegister: (formData: RegisterFormData) => Promise; -} - -/** - * 从邮箱提取用户名(@前面的部分) - */ -const getUsernameFromEmail = (email: string): string => { - if (!email || !email.includes('@')) return ''; - return email.split('@')[0]; -}; - -/** - * 注册操作 Hook - */ -export function useRegister(): UseRegisterReturn { - const navigate = useNavigate(); - const { setUser } = useAuth(); - const { setLoading } = useLoading(); - useTranslation('auth'); // 注册 i18n namespace - - const handleRegister = useCallback( - async (formData: RegisterFormData) => { - setLoading(true); - - try { - // 通过 Tauri 命令注册(会自动处理公钥、机器信息和 token 保存) - const responseData = await register(formData.email, formData.password, formData.code); - - // 解析用户信息 - if (responseData.user_info) { - const userInfo = responseData.user_info; - const username = getUsernameFromEmail(userInfo.email || formData.email); - setUser({ - uuid: userInfo.uuid || '', - id: userInfo.id || '', - nickname: userInfo.nickname || username, - email: userInfo.email || formData.email, - phone: userInfo.phone, - avatar: userInfo.avatar_hash, - status: userInfo.status || 'active', - }); - } else { - // 如果没有用户信息,使用邮箱提取的用户名 - const username = getUsernameFromEmail(formData.email); - setUser({ - uuid: '', - id: username, - nickname: username, - email: formData.email, - status: 'active', - }); - } - - // 注册成功后跳转到首页 - navigate('/'); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : '注册失败'; - toast.error(message); - throw error; - } finally { - setLoading(false); - } - }, - [navigate, setUser, setLoading] - ); - - return { - handleRegister, - }; -} diff --git a/plugins/pages/register/src/types/index.ts b/plugins/pages/register/src/types/index.ts deleted file mode 100644 index 01013352..00000000 --- a/plugins/pages/register/src/types/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * 注册表单数据 - */ -export interface RegisterFormData { - email: string; - password: string; - confirmPassword: string; - code: string; -} diff --git a/plugins/pages/reset-password/manifest.json b/plugins/pages/reset-password/manifest.json deleted file mode 100644 index d2b044c7..00000000 --- a/plugins/pages/reset-password/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "reset-password", - "name": "Reset Password", - "version": "1.0.0", - "description": "重置密码页面插件", - "author": "Simprint Team", - "entry": "./src/index.tsx", - "slots": [], - "enabled": true -} diff --git a/plugins/pages/reset-password/package.json b/plugins/pages/reset-password/package.json deleted file mode 100644 index 2a515fa9..00000000 --- a/plugins/pages/reset-password/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "plugin-reset-password", - "version": "1.0.0", - "main": "src/index.tsx", - "private": true -} diff --git a/plugins/pages/reset-password/src/api/index.ts b/plugins/pages/reset-password/src/api/index.ts deleted file mode 100644 index dfa1a463..00000000 --- a/plugins/pages/reset-password/src/api/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * 重置密码 API 服务 - */ -import { post, isSuccess } from '@/lib/request'; -import type { SendCodeRequest, ResetPasswordRequest, CodeType } from './index.types'; - -// 导出类型 -export * from './index.types'; - -// API 端点配置 -export const API_ENDPOINTS = { - SEND_CODE: 'users/reset-password-send-code', - RESET_PASSWORD: 'users/reset-password', -} as const; - -/** - * 发送重置密码验证码 - */ -export async function sendResetCode( - email: string, - type: CodeType = 'reset_password' -): Promise { - const requestData: SendCodeRequest = { - email, - type, - }; - - const result = await post(API_ENDPOINTS.SEND_CODE, requestData); - - if (!isSuccess(result)) { - throw new Error(result.message || '发送验证码失败'); - } -} - -/** - * 重置密码 - */ -export async function resetPassword( - email: string, - code: string, - newPassword: string -): Promise { - const requestData: ResetPasswordRequest = { - email, - code, - new_password: newPassword, - }; - - const result = await post(API_ENDPOINTS.RESET_PASSWORD, requestData); - - if (!isSuccess(result)) { - throw new Error(result.message || '重置密码失败'); - } -} diff --git a/plugins/pages/reset-password/src/api/index.types.ts b/plugins/pages/reset-password/src/api/index.types.ts deleted file mode 100644 index 6ad08d90..00000000 --- a/plugins/pages/reset-password/src/api/index.types.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * 重置密码 API 类型定义 - */ - -/** - * 验证码类型 - */ -export type CodeType = 'register' | 'reset_password'; - -/** - * 发送验证码请求 - */ -export interface SendCodeRequest { - email: string; - type: CodeType; -} - -/** - * 重置密码请求 - */ -export interface ResetPasswordRequest { - email: string; - code: string; - new_password: string; -} diff --git a/plugins/pages/reset-password/src/components/code-input.tsx b/plugins/pages/reset-password/src/components/code-input.tsx deleted file mode 100644 index 40e5dbb1..00000000 --- a/plugins/pages/reset-password/src/components/code-input.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Key, Send } from 'lucide-react'; -import { TextareaInput } from '@/components/textarea-input'; -import { Button } from '@/components/ui/button'; -import { CODE_MAX_LENGTH } from '../constants'; - -interface CodeInputProps { - code: string; - countdown: number; - error?: string; - onCodeChange: (code: string) => void; - onResend: () => void; -} - -/** - * 验证码输入组件 - */ -export const CodeInput: React.FC = ({ - code, - countdown, - error, - onCodeChange, - onResend, -}) => { - if (countdown > 0) { - // 倒计时中:只显示输入框 - return ( - <> -
- - onCodeChange(e.target.value)} - aria-invalid={!!error} - className="pl-9" - required - maxLength={CODE_MAX_LENGTH} - /> -
- {error &&

{error}

} -

验证码已发送,{countdown} 秒后可重新发送

- - ); - } - - // 过期后:输入框和发送按钮在同一行 - return ( - <> -
-
- - onCodeChange(e.target.value)} - aria-invalid={!!error} - className="pl-9" - required - maxLength={CODE_MAX_LENGTH} - /> -
- -
- {error &&

{error}

} - - ); -}; diff --git a/plugins/pages/reset-password/src/components/reset-form.tsx b/plugins/pages/reset-password/src/components/reset-form.tsx deleted file mode 100644 index a7dfeb21..00000000 --- a/plugins/pages/reset-password/src/components/reset-form.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useResetForm } from '../hooks/use-reset-form'; -import { useResetValidation } from '../hooks/use-reset-validation'; -import { useResetCode } from '../hooks/use-reset-code'; -import { useResetPassword } from '../hooks/use-reset-password'; -import { useResetHandlers } from '../hooks/use-reset-handlers'; -import { ResetStep1 } from './reset-step1'; -import { ResetStep2 } from './reset-step2'; - -export const ResetForm: React.FC = () => { - const { t } = useTranslation('auth'); - - // 表单状态管理 - const form = useResetForm(); - - // 表单验证 - const validation = useResetValidation(); - - // 验证码管理 - const code = useResetCode(); - - // 重置密码操作 - const resetPassword = useResetPassword(); - - // 事件处理 - const handlers = useResetHandlers({ - form, - validation, - code, - resetPassword, - }); - - return ( - <> -
-

{t('reset.title')}

-

- {form.step === 1 ? t('reset.subtitleStep1') : t('reset.subtitleStep2')} -

-
- -
- {form.step === 1 ? ( - - ) : ( - - )} - - - ); -}; diff --git a/plugins/pages/reset-password/src/components/reset-step1.tsx b/plugins/pages/reset-password/src/components/reset-step1.tsx deleted file mode 100644 index 66380eeb..00000000 --- a/plugins/pages/reset-password/src/components/reset-step1.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Mail, ArrowLeft, Send } from 'lucide-react'; -import { useNavigate } from 'react-router'; -import { TextareaInput } from '@/components/textarea-input'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { useTranslation } from 'react-i18next'; - -interface ResetStep1Props { - email: string; - emailError?: string; - onEmailChange: (email: string) => void; - onSubmit: () => void; -} - -/** - * 重置密码第一步:发送验证码 - */ -export const ResetStep1: React.FC = ({ - email, - emailError, - onEmailChange, - onSubmit, -}) => { - const navigate = useNavigate(); - const { t } = useTranslation('auth'); - - return ( - <> -
- -
- - onEmailChange(e.target.value)} - aria-invalid={!!emailError} - className="pl-9" - required - /> -
- {emailError &&

{emailError}

} -
- -

- {t('reset.hint')} -

- -
- - -
- - ); -}; diff --git a/plugins/pages/reset-password/src/components/reset-step2.tsx b/plugins/pages/reset-password/src/components/reset-step2.tsx deleted file mode 100644 index 2e315370..00000000 --- a/plugins/pages/reset-password/src/components/reset-step2.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { ArrowLeft, Lock } from 'lucide-react'; -import { useNavigate } from 'react-router'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { CodeInput } from './code-input'; - -interface ResetStep2Props { - code: string; - newPassword: string; - confirmPassword: string; - countdown: number; - codeError?: string; - passwordError?: string; - confirmPasswordError?: string; - onCodeChange: (code: string) => void; - onNewPasswordChange: (password: string) => void; - onConfirmPasswordChange: (password: string) => void; - onResendCode: () => void; -} - -/** - * 重置密码第二步:输入验证码和新密码 - */ -export const ResetStep2: React.FC = ({ - code, - newPassword, - confirmPassword, - countdown, - codeError, - passwordError, - confirmPasswordError, - onCodeChange, - onNewPasswordChange, - onConfirmPasswordChange, - onResendCode, -}) => { - const navigate = useNavigate(); - - return ( - <> -
- -
- -
-
- -
- -
- - onNewPasswordChange(e.target.value)} - aria-invalid={!!passwordError} - className="pl-9" - required - minLength={8} - /> -
- {passwordError &&

{passwordError}

} -
- -
- -
- - onConfirmPasswordChange(e.target.value)} - aria-invalid={!!confirmPasswordError} - className="pl-9" - required - /> -
- {confirmPasswordError &&

{confirmPasswordError}

} -
- -
- - -
- - ); -}; diff --git a/plugins/pages/reset-password/src/constants.ts b/plugins/pages/reset-password/src/constants.ts deleted file mode 100644 index 94632639..00000000 --- a/plugins/pages/reset-password/src/constants.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 邮箱验证正则 - * - 用户名部分:允许大小写字母、数字、点、连字符、下划线 - * - 域名部分:只允许小写字母、数字、连字符 - */ -export const EMAIL_REGEX = - /^[^\s@]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; - -export const MIN_PASSWORD_LENGTH = 8; - -export const CODE_COUNTDOWN_SECONDS = 60; - -export const CODE_MAX_LENGTH = 6; diff --git a/plugins/pages/reset-password/src/hooks/use-reset-code.ts b/plugins/pages/reset-password/src/hooks/use-reset-code.ts deleted file mode 100644 index 999e52c1..00000000 --- a/plugins/pages/reset-password/src/hooks/use-reset-code.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { toast } from 'sonner'; -import { useLoading } from '../../../../services/store/src'; -import { sendResetCode } from '../api'; -import { CODE_COUNTDOWN_SECONDS } from '../constants'; - -export interface UseResetCodeReturn { - codeSent: boolean; - countdown: number; - sendCode: (email: string) => Promise; - resetCode: () => void; -} - -/** - * 重置密码验证码管理 Hook - */ -export function useResetCode(): UseResetCodeReturn { - const { setLoading } = useLoading(); - const [codeSent, setCodeSent] = useState(false); - const [countdown, setCountdown] = useState(0); - - // 倒计时效果 - useEffect(() => { - if (countdown > 0) { - const timer = setTimeout(() => setCountdown(countdown - 1), 1000); - return () => clearTimeout(timer); - } - }, [countdown]); - - const sendCode = useCallback( - async (email: string) => { - setLoading(true); - - try { - await sendResetCode(email, 'reset_password'); - setCodeSent(true); - setCountdown(CODE_COUNTDOWN_SECONDS); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : '发送验证码失败'; - toast.error(message); - throw error; - } finally { - setLoading(false); - } - }, - [setLoading] - ); - - const resetCode = useCallback(() => { - setCodeSent(false); - setCountdown(0); - }, []); - - return { - codeSent, - countdown, - sendCode, - resetCode, - }; -} diff --git a/plugins/pages/reset-password/src/hooks/use-reset-form.ts b/plugins/pages/reset-password/src/hooks/use-reset-form.ts deleted file mode 100644 index 5142f5a1..00000000 --- a/plugins/pages/reset-password/src/hooks/use-reset-form.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { ResetFormData, ResetStep } from '../types'; - -export interface UseResetFormReturn { - step: ResetStep; - formData: ResetFormData; - setStep: (step: ResetStep) => void; - setEmail: (email: string) => void; - setCode: (code: string) => void; - setNewPassword: (password: string) => void; - setConfirmPassword: (password: string) => void; - resetForm: () => void; -} - -/** - * 重置密码表单状态管理 Hook - */ -export function useResetForm(): UseResetFormReturn { - const [step, setStep] = useState(1); - const [formData, setFormData] = useState({ - email: '', - code: '', - newPassword: '', - confirmPassword: '', - }); - - const setEmail = useCallback((email: string) => { - setFormData((prev) => ({ ...prev, email })); - }, []); - - const setCode = useCallback((code: string) => { - setFormData((prev) => ({ ...prev, code })); - }, []); - - const setNewPassword = useCallback((password: string) => { - setFormData((prev) => ({ ...prev, newPassword: password })); - }, []); - - const setConfirmPassword = useCallback((password: string) => { - setFormData((prev) => ({ ...prev, confirmPassword: password })); - }, []); - - const resetForm = useCallback(() => { - setStep(1); - setFormData({ - email: '', - code: '', - newPassword: '', - confirmPassword: '', - }); - }, []); - - return { - step, - formData, - setStep, - setEmail, - setCode, - setNewPassword, - setConfirmPassword, - resetForm, - }; -} diff --git a/plugins/pages/reset-password/src/hooks/use-reset-handlers.ts b/plugins/pages/reset-password/src/hooks/use-reset-handlers.ts deleted file mode 100644 index 3b61d0c9..00000000 --- a/plugins/pages/reset-password/src/hooks/use-reset-handlers.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { useCallback } from 'react'; -import type { UseResetFormReturn } from './use-reset-form'; -import type { UseResetValidationReturn } from './use-reset-validation'; -import type { UseResetCodeReturn } from './use-reset-code'; -import type { UseResetPasswordReturn } from './use-reset-password'; - -interface UseResetHandlersParams { - form: UseResetFormReturn; - validation: UseResetValidationReturn; - code: UseResetCodeReturn; - resetPassword: UseResetPasswordReturn; -} - -interface UseResetHandlersReturn { - handleSendCode: () => Promise; - handleSubmit: (e: React.FormEvent) => Promise; - handleEmailChange: (value: string) => void; - handleCodeChange: (value: string) => void; - handleNewPasswordChange: (value: string) => void; - handleConfirmPasswordChange: (value: string) => void; -} - -/** - * 重置密码事件处理 Hook - * 整合所有事件处理逻辑 - */ -export function useResetHandlers(params: UseResetHandlersParams): UseResetHandlersReturn { - const { form, validation, code, resetPassword } = params; - - // 处理发送验证码 - const handleSendCode = useCallback(async () => { - validation.clearErrors(); - - // 验证邮箱 - if (!validation.validateStep1(form.formData.email)) { - return; - } - - try { - await code.sendCode(form.formData.email); - form.setStep(2); // 进入第二步 - } catch (error: any) { - validation.setEmailError(error?.message || error?.toString() || '发送验证码失败'); - } - }, [form, validation, code]); - - // 处理表单提交 - const handleSubmit = useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - - if (form.step === 1) { - // 第一步:发送验证码 - await handleSendCode(); - return; - } - - // 第二步:重置密码 - validation.clearErrors(); - - // 验证表单 - if ( - !validation.validateStep2({ - code: form.formData.code, - newPassword: form.formData.newPassword, - confirmPassword: form.formData.confirmPassword, - }) - ) { - return; - } - - // 执行重置密码 - try { - await resetPassword.handleResetPassword(form.formData); - } catch (error: any) { - console.error('重置密码失败:', error); - const errorMessage = error?.message || error?.toString() || '重置密码失败,请重试'; - validation.setCodeError(errorMessage); - } - }, - [form, validation, resetPassword, handleSendCode] - ); - - // 处理邮箱变化 - const handleEmailChange = useCallback( - (value: string) => { - form.setEmail(value); - validation.clearErrors(); - }, - [form, validation] - ); - - // 处理验证码变化 - const handleCodeChange = useCallback( - (value: string) => { - form.setCode(value); - validation.clearErrors(); - }, - [form, validation] - ); - - // 处理新密码变化 - const handleNewPasswordChange = useCallback( - (value: string) => { - form.setNewPassword(value); - validation.clearErrors(); - }, - [form, validation] - ); - - // 处理确认密码变化 - const handleConfirmPasswordChange = useCallback( - (value: string) => { - form.setConfirmPassword(value); - validation.clearErrors(); - }, - [form, validation] - ); - - return { - handleSendCode, - handleSubmit, - handleEmailChange, - handleCodeChange, - handleNewPasswordChange, - handleConfirmPasswordChange, - }; -} diff --git a/plugins/pages/reset-password/src/hooks/use-reset-password.ts b/plugins/pages/reset-password/src/hooks/use-reset-password.ts deleted file mode 100644 index a918e0a7..00000000 --- a/plugins/pages/reset-password/src/hooks/use-reset-password.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useCallback } from 'react'; -import { useNavigate } from 'react-router'; -import { toast } from 'sonner'; -import { useLoading } from '../../../../services/store/src'; -import type { ResetFormData } from '../types'; -import { resetPassword } from '../api'; - -export interface UseResetPasswordReturn { - handleResetPassword: (formData: ResetFormData) => Promise; -} - -/** - * 重置密码操作 Hook - */ -export function useResetPassword(): UseResetPasswordReturn { - const navigate = useNavigate(); - const { setLoading } = useLoading(); - - const handleResetPassword = useCallback( - async (formData: ResetFormData) => { - setLoading(true); - - try { - await resetPassword(formData.email, formData.code, formData.newPassword); - - // 重置成功,显示成功提示并跳转到登录页 - toast.success('密码重置成功', { - description: '请使用新密码登录', - }); - - // 延迟跳转,让用户看到提示 - setTimeout(() => { - navigate('/auth/login'); - }, 1000); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : '重置密码失败'; - toast.error(message); - throw error; - } finally { - setLoading(false); - } - }, - [navigate, setLoading] - ); - - return { - handleResetPassword, - }; -} diff --git a/plugins/pages/reset-password/src/hooks/use-reset-validation.ts b/plugins/pages/reset-password/src/hooks/use-reset-validation.ts deleted file mode 100644 index 955b8934..00000000 --- a/plugins/pages/reset-password/src/hooks/use-reset-validation.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { EMAIL_REGEX, MIN_PASSWORD_LENGTH } from '../constants'; - -interface ValidationErrors { - email: string; - code: string; - password: string; - confirmPassword: string; -} - -export interface UseResetValidationReturn { - errors: ValidationErrors; - validateEmail: (email: string) => boolean; - validatePassword: (password: string) => boolean; - validateConfirmPassword: (password: string, confirmPassword: string) => boolean; - validateCode: (code: string) => boolean; - validateStep1: (email: string) => boolean; - validateStep2: (formData: { - code: string; - newPassword: string; - confirmPassword: string; - }) => boolean; - clearErrors: () => void; - setEmailError: (error: string) => void; - setCodeError: (error: string) => void; - setPasswordError: (error: string) => void; - setConfirmPasswordError: (error: string) => void; -} - -/** - * 重置密码表单验证 Hook - */ -export function useResetValidation(): UseResetValidationReturn { - const { t } = useTranslation('auth'); - const [errors, setErrors] = useState({ - email: '', - code: '', - password: '', - confirmPassword: '', - }); - - const validateEmail = useCallback( - (email: string): boolean => { - if (!email) { - setErrors((prev) => ({ ...prev, email: t('login.err.emailRequired') })); - return false; - } - - if (!EMAIL_REGEX.test(email)) { - setErrors((prev) => ({ ...prev, email: t('login.err.emailInvalid') })); - return false; - } - - setErrors((prev) => ({ ...prev, email: '' })); - return true; - }, - [t] - ); - - const validatePassword = useCallback((password: string): boolean => { - if (!password) { - setErrors((prev) => ({ ...prev, password: '请输入新密码' })); - return false; - } - - if (password.length < MIN_PASSWORD_LENGTH) { - setErrors((prev) => ({ ...prev, password: `密码至少需要${MIN_PASSWORD_LENGTH}个字符` })); - return false; - } - - setErrors((prev) => ({ ...prev, password: '' })); - return true; - }, []); - - const validateConfirmPassword = useCallback( - (password: string, confirmPassword: string): boolean => { - if (!confirmPassword) { - setErrors((prev) => ({ ...prev, confirmPassword: '请再次输入密码' })); - return false; - } - - if (password !== confirmPassword) { - setErrors((prev) => ({ ...prev, confirmPassword: '两次输入的密码不一致' })); - return false; - } - - setErrors((prev) => ({ ...prev, confirmPassword: '' })); - return true; - }, - [] - ); - - const validateCode = useCallback((code: string): boolean => { - if (!code) { - setErrors((prev) => ({ ...prev, code: '请输入验证码' })); - return false; - } - - setErrors((prev) => ({ ...prev, code: '' })); - return true; - }, []); - - const validateStep1 = useCallback( - (email: string): boolean => { - return validateEmail(email); - }, - [validateEmail] - ); - - const validateStep2 = useCallback( - (formData: { code: string; newPassword: string; confirmPassword: string }): boolean => { - const isCodeValid = validateCode(formData.code); - const isPasswordValid = validatePassword(formData.newPassword); - const isConfirmPasswordValid = validateConfirmPassword( - formData.newPassword, - formData.confirmPassword - ); - return isCodeValid && isPasswordValid && isConfirmPasswordValid; - }, - [validateCode, validatePassword, validateConfirmPassword] - ); - - const clearErrors = useCallback(() => { - setErrors({ - email: '', - code: '', - password: '', - confirmPassword: '', - }); - }, []); - - const setEmailError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, email: error })); - }, []); - - const setCodeError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, code: error })); - }, []); - - const setPasswordError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, password: error })); - }, []); - - const setConfirmPasswordError = useCallback((error: string) => { - setErrors((prev) => ({ ...prev, confirmPassword: error })); - }, []); - - return { - errors, - validateEmail, - validatePassword, - validateConfirmPassword, - validateCode, - validateStep1, - validateStep2, - clearErrors, - setEmailError, - setCodeError, - setPasswordError, - setConfirmPasswordError, - }; -} diff --git a/plugins/pages/reset-password/src/index.tsx b/plugins/pages/reset-password/src/index.tsx deleted file mode 100644 index e64ede70..00000000 --- a/plugins/pages/reset-password/src/index.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { extensionRegistry } from '@slotkitjs/core'; -import { ResetForm } from './components/reset-form'; - -// 页面组件 -const ResetPasswordPage: React.FC = () => { - return ; -}; - -// 在模块加载时贡献路由 -try { - extensionRegistry.contribute('routes', { - contributorId: 'reset-password', - value: { - path: '/auth/reset-password', - Component: ResetPasswordPage, - }, - priority: 10, - }); - console.log('[reset-password] Route contributed at module load: /auth/reset-password'); -} catch (error) { - console.warn( - '[reset-password] Failed to contribute route at module load (extension point may not be registered yet):', - error - ); -} - -const resetPasswordPlugin = { - id: 'reset-password', - name: 'Reset Password', - version: '1.0.0', - component: ResetPasswordPage, - slots: [], -}; - -export default resetPasswordPlugin; diff --git a/plugins/pages/reset-password/src/types/index.ts b/plugins/pages/reset-password/src/types/index.ts deleted file mode 100644 index 9560780c..00000000 --- a/plugins/pages/reset-password/src/types/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * 重置密码步骤 - */ -export type ResetStep = 1 | 2; - -/** - * 重置密码表单数据 - */ -export interface ResetFormData { - email: string; - code: string; - newPassword: string; - confirmPassword: string; -} diff --git a/plugins/pages/reset-password/tsconfig.json b/plugins/pages/reset-password/tsconfig.json deleted file mode 100644 index f5b67230..00000000 --- a/plugins/pages/reset-password/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "jsx": "react-jsx" - }, - "include": ["src/**/*"] -} diff --git a/plugins/pages/rpa-workflow/src/constants.ts b/plugins/pages/rpa-workflow/src/constants.ts deleted file mode 100644 index f4de497a..00000000 --- a/plugins/pages/rpa-workflow/src/constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const ITEMS_PER_PAGE = 10; - -export const API_ENDPOINTS = { - WORKFLOWS: '/api/v1/rpa/workflows', - RUN: (id: string) => `/api/v1/rpa/workflows/${id}/run`, - STOP: (id: string) => `/api/v1/rpa/workflows/${id}/stop`, - DELETE: (id: string) => `/api/v1/rpa/workflows/${id}`, - DUPLICATE: (id: string) => `/api/v1/rpa/workflows/${id}/duplicate`, - BATCH_RUN: '/api/v1/rpa/workflows/batch-run', - BATCH_DELETE: '/api/v1/rpa/workflows/batch-delete', -} as const; diff --git a/plugins/pages/rpa-workflow/src/runtime/anonymous-environment.ts b/plugins/pages/rpa-workflow/src/runtime/anonymous-environment.ts index 027d5b14..f528ef7a 100644 --- a/plugins/pages/rpa-workflow/src/runtime/anonymous-environment.ts +++ b/plugins/pages/rpa-workflow/src/runtime/anonymous-environment.ts @@ -92,7 +92,7 @@ export async function startAnonymousRpaEnvironment( const exePath = await invoke('ensure_kernel_ready', { envUuid: anonymousEnvUuid, - kernelValue: kernelDetail.resource_name, + kernelValue: kernelDetail.kernel_id, profilesPath: effectiveProfiles, kernelDetail: { url: kernelDetail.url, diff --git a/plugins/pages/splashscreen/manifest.json b/plugins/pages/splashscreen/manifest.json deleted file mode 100644 index 82c39cf2..00000000 --- a/plugins/pages/splashscreen/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "splashscreen", - "name": "Splashscreen", - "version": "1.0.0", - "description": "启动加载页面插件", - "author": "Simprint Team", - "entry": "./src/index.tsx", - "slots": [], - "enabled": true -} diff --git a/plugins/pages/splashscreen/package.json b/plugins/pages/splashscreen/package.json deleted file mode 100644 index 4e5ca0b6..00000000 --- a/plugins/pages/splashscreen/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "plugin-splashscreen", - "version": "1.0.0", - "main": "src/index.tsx", - "private": true -} diff --git a/plugins/pages/splashscreen/src/components/SplashscreenBackground.tsx b/plugins/pages/splashscreen/src/components/SplashscreenBackground.tsx deleted file mode 100644 index a4d5dbfc..00000000 --- a/plugins/pages/splashscreen/src/components/SplashscreenBackground.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useGridAnimation } from '../hooks/useGridAnimation'; - -/** - * Splashscreen 背景组件 - * 包含高密度网格动画和背景光效 - */ -export const SplashscreenBackground: React.FC = () => { - const canvasRef = useGridAnimation({ - cellSize: 30, - lineWidth: 0.5, - color: 'rgba(37, 99, 235, 0.06)', // 使用与登录界面相同的蓝色 (blue-600) - rotation: 90, - speed: 0.3, - }); - - return ( - <> - - {/* 背景光效 - 使用与登录界面相同的蓝色 (blue-600: rgba(37, 99, 235)) */} -
-
- - ); -}; diff --git a/plugins/pages/splashscreen/src/components/SplashscreenContent.tsx b/plugins/pages/splashscreen/src/components/SplashscreenContent.tsx deleted file mode 100644 index c2ee7a8b..00000000 --- a/plugins/pages/splashscreen/src/components/SplashscreenContent.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Splashscreen 内容组件 - * 包含标题 - */ -export const SplashscreenContent: React.FC = () => { - return ( -
-

- - {/* Simprint */} - -

-
- ); -}; diff --git a/plugins/pages/splashscreen/src/components/SplashscreenDecoration.tsx b/plugins/pages/splashscreen/src/components/SplashscreenDecoration.tsx deleted file mode 100644 index 0facda88..00000000 --- a/plugins/pages/splashscreen/src/components/SplashscreenDecoration.tsx +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Splashscreen 装饰组件 - * 包含底部装饰线等装饰元素 - */ -export const SplashscreenDecoration: React.FC = () => { - return ( -
- ); -}; diff --git a/plugins/pages/splashscreen/src/components/SplashscreenErrorMessage.tsx b/plugins/pages/splashscreen/src/components/SplashscreenErrorMessage.tsx deleted file mode 100644 index 21110d31..00000000 --- a/plugins/pages/splashscreen/src/components/SplashscreenErrorMessage.tsx +++ /dev/null @@ -1,14 +0,0 @@ -interface SplashscreenErrorMessageProps { - message: string; -} - -/** - * Splashscreen 错误消息组件 - */ -export const SplashscreenErrorMessage: React.FC = ({ message }) => { - return ( -
- {message} -
- ); -}; diff --git a/plugins/pages/splashscreen/src/components/SplashscreenLoadingText.tsx b/plugins/pages/splashscreen/src/components/SplashscreenLoadingText.tsx deleted file mode 100644 index 33757090..00000000 --- a/plugins/pages/splashscreen/src/components/SplashscreenLoadingText.tsx +++ /dev/null @@ -1,40 +0,0 @@ -interface SplashscreenLoadingTextProps { - loadingText: string; - progress?: number; - isUpdating?: boolean; -} - -/** - * Splashscreen 加载文本组件 - * 显示在右下角 - */ -export const SplashscreenLoadingText: React.FC = ({ - loadingText, - progress, - isUpdating, -}) => { - const displayProgress = - typeof progress === 'number' && !Number.isNaN(progress) - ? Math.max(0, Math.min(100, Math.round(progress))) - : null; - - return ( -
- {isUpdating && ( - <> - - {displayProgress !== null && ( - {displayProgress}% - )} - - )} - {loadingText} -
- ); -}; diff --git a/plugins/pages/splashscreen/src/components/SplashscreenLogo.tsx b/plugins/pages/splashscreen/src/components/SplashscreenLogo.tsx deleted file mode 100644 index 2b602ed6..00000000 --- a/plugins/pages/splashscreen/src/components/SplashscreenLogo.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Splashscreen Logo 组件 - * 包含光晕、旋转光环、Logo 图片和悬浮粒子 - */ -export const SplashscreenLogo: React.FC = () => { - return ( -
- {/* 外层光晕 - 多层叠加 - 使用与登录界面相同的蓝色 */} -
-
-
-
- - {/* 旋转光环 */} -
-
-
- - {/* LOGO主体 */} -
- Simprint Logo -
- - {/* 悬浮粒子环绕 - 使用与登录界面相同的蓝色 */} -
-
-
-
-
-
-
- ); -}; diff --git a/plugins/pages/splashscreen/src/components/index.ts b/plugins/pages/splashscreen/src/components/index.ts deleted file mode 100644 index 6529d0f2..00000000 --- a/plugins/pages/splashscreen/src/components/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Splashscreen 组件统一导出 - */ -export { SplashscreenBackground } from './SplashscreenBackground'; -export { SplashscreenLogo } from './SplashscreenLogo'; -export { SplashscreenContent } from './SplashscreenContent'; -export { SplashscreenLoadingText } from './SplashscreenLoadingText'; -export { SplashscreenErrorMessage } from './SplashscreenErrorMessage'; -export { SplashscreenDecoration } from './SplashscreenDecoration'; -export { SplashscreenCloseButton } from './splashscreen-close-button'; diff --git a/plugins/pages/splashscreen/src/components/splashscreen-close-button.tsx b/plugins/pages/splashscreen/src/components/splashscreen-close-button.tsx deleted file mode 100644 index 6c240d14..00000000 --- a/plugins/pages/splashscreen/src/components/splashscreen-close-button.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { X } from 'lucide-react'; - -interface SplashscreenCloseButtonProps { - onClose: () => void; -} - -/** - * Splashscreen 关闭按钮组件 - */ -export const SplashscreenCloseButton: React.FC = ({ onClose }) => { - return ( - - ); -}; diff --git a/plugins/pages/splashscreen/src/hooks/index.ts b/plugins/pages/splashscreen/src/hooks/index.ts deleted file mode 100644 index d1470637..00000000 --- a/plugins/pages/splashscreen/src/hooks/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Splashscreen Hooks 统一导出 - */ -export { useSplashscreenWindow } from './useSplashscreenWindow'; -export { useSplashscreenEvents } from './useSplashscreenEvents'; -export { useSplashscreenInit } from './useSplashscreenInit'; -export { useParticleAnimation } from './useParticleAnimation'; diff --git a/plugins/pages/splashscreen/src/hooks/use-splashscreen-close.ts b/plugins/pages/splashscreen/src/hooks/use-splashscreen-close.ts deleted file mode 100644 index bd784c6b..00000000 --- a/plugins/pages/splashscreen/src/hooks/use-splashscreen-close.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useCallback } from 'react'; -import { invoke } from '@/lib/tauri'; - -/** - * Splashscreen 关闭处理 Hook - */ -export function useSplashscreenClose() { - const handleClose = useCallback(async () => { - try { - await invoke('close_program'); // 直接请求后端退出应用 - } catch (error) { - console.error('[SplashscreenClose] 退出应用失败:', error); - } - }, []); - - return { - handleClose, - }; -} diff --git a/plugins/pages/splashscreen/src/hooks/use-splashscreen-window-display.ts b/plugins/pages/splashscreen/src/hooks/use-splashscreen-window-display.ts deleted file mode 100644 index ff822d51..00000000 --- a/plugins/pages/splashscreen/src/hooks/use-splashscreen-window-display.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { getCurrentWindow } from '@tauri-apps/api/window'; -import { invoke } from '@/lib/tauri'; - -interface UseSplashscreenWindowDisplayReturn { - contentRef: React.RefObject; -} - -/** - * Splashscreen 窗口显示控制 Hook - * 负责在内容准备好后显示窗口并通知后端 - */ -export function useSplashscreenWindowDisplay(): UseSplashscreenWindowDisplayReturn { - const contentRef = useRef(null); - const hasShownWindow = useRef(false); - - useEffect(() => { - let animationFrameId: number; - - const checkContentReady = () => { - // 检查 DOM 元素是否已存在且可见(有高度) - const isContentReady = - contentRef.current && - contentRef.current.offsetHeight > 0 && - contentRef.current.offsetWidth > 0; - - if (isContentReady && !hasShownWindow.current) { - // 内容已渲染,使用双重 RAF + setTimeout 确保浏览器完成渲染后再显示窗口 - hasShownWindow.current = true; - - requestAnimationFrame(() => { - requestAnimationFrame(async () => { - // 额外的延迟确保所有样式、布局和动画都已完成 - setTimeout(async () => { - try { - const splashWindow = getCurrentWindow(); - - // 检查窗口标签是否匹配 - if (splashWindow.label.includes('splashscreen')) { - await splashWindow.show(); - await splashWindow.setFocus(); - // 窗口显示后,通知后端开始加载 - try { - await invoke('splashscreen_ready'); - } catch (error) { - console.error('[SplashscreenWindowDisplay] 通知后端开始加载失败:', error); - } - } - } catch (error) { - console.error('[SplashscreenWindowDisplay] 显示窗口失败:', error); - } - }, 150); - }); - }); - } else if (!isContentReady) { - // 内容还未准备好,继续检查(使用 RAF 避免阻塞) - animationFrameId = requestAnimationFrame(checkContentReady); - } - }; - - // 立即开始检查,使用 RAF 循环直到内容准备好 - animationFrameId = requestAnimationFrame(checkContentReady); - - return () => { - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - }; - }, []); - - return { - contentRef, - }; -} diff --git a/plugins/pages/splashscreen/src/hooks/useGridAnimation.ts b/plugins/pages/splashscreen/src/hooks/useGridAnimation.ts deleted file mode 100644 index cd60014c..00000000 --- a/plugins/pages/splashscreen/src/hooks/useGridAnimation.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { useEffect, useRef } from 'react'; - -interface GridConfig { - cellSize?: number; - lineWidth?: number; - color?: string; - rotation?: number; - speed?: number; -} - -/** - * 高密度网格动画 Hook - * 负责管理 Canvas 网格动画效果 - */ -export function useGridAnimation(config: GridConfig = {}) { - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const { - cellSize = 30, - lineWidth = 0.5, - color = 'rgba(37, 99, 235, 0.06)', // 使用与登录界面相同的蓝色 (blue-600) - rotation = 90, - speed = 0.3, - } = config; - - // 设置画布尺寸 - const resizeCanvas = () => { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - }; - resizeCanvas(); - window.addEventListener('resize', resizeCanvas); - - let offset = 0; - let animationId: number; - - function drawGrid() { - if (!ctx || !canvas) return; - - // 清空画布 - ctx.clearRect(0, 0, canvas.width, canvas.height); - - // 保存当前状态 - ctx.save(); - - // 移动到画布中心 - ctx.translate(canvas.width / 2, canvas.height / 2); - - // 旋转网格(90度效果) - ctx.rotate((rotation * Math.PI) / 180); - - // 设置样式 - ctx.strokeStyle = color; - ctx.lineWidth = lineWidth; - - // 计算需要绘制的网格范围(覆盖整个屏幕) - const diagonal = Math.sqrt(canvas.width ** 2 + canvas.height ** 2); - const halfSize = diagonal / 2; - - // 绘制垂直线(旋转后会变成水平) - const verticalOffset = offset % cellSize; - for (let x = -halfSize; x <= halfSize; x += cellSize) { - ctx.beginPath(); - ctx.moveTo(x + verticalOffset, -halfSize); - ctx.lineTo(x + verticalOffset, halfSize); - ctx.stroke(); - } - - // 绘制水平线(旋转后会变成垂直) - const horizontalOffset = offset % cellSize; - for (let y = -halfSize; y <= halfSize; y += cellSize) { - ctx.beginPath(); - ctx.moveTo(-halfSize, y + horizontalOffset); - ctx.lineTo(halfSize, y + horizontalOffset); - ctx.stroke(); - } - - // 恢复状态 - ctx.restore(); - - // 更新偏移量 - offset += speed; - } - - function animate() { - drawGrid(); - animationId = requestAnimationFrame(animate); - } - animate(); - - return () => { - window.removeEventListener('resize', resizeCanvas); - cancelAnimationFrame(animationId); - }; - }, [config]); - - return canvasRef; -} diff --git a/plugins/pages/splashscreen/src/hooks/useParticleAnimation.ts b/plugins/pages/splashscreen/src/hooks/useParticleAnimation.ts deleted file mode 100644 index 0d1fb60b..00000000 --- a/plugins/pages/splashscreen/src/hooks/useParticleAnimation.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { useEffect, useRef } from 'react'; -import type { ParticleConfig } from '../types'; - -/** - * 粒子类 - */ -class Particle { - x: number; - y: number; - size: number; - speedX: number; - speedY: number; - opacity: number; - canvas: HTMLCanvasElement; - ctx: CanvasRenderingContext2D; - maxSize: number; - minSize: number; - speed: number; - color: string; - - constructor( - canvas: HTMLCanvasElement, - ctx: CanvasRenderingContext2D, - maxSize: number, - minSize: number, - speed: number, - color: string - ) { - this.canvas = canvas; - this.ctx = ctx; - this.maxSize = maxSize; - this.minSize = minSize; - this.speed = speed; - this.color = color; - this.x = Math.random() * canvas.width; - this.y = Math.random() * canvas.height; - this.size = Math.random() * (maxSize - minSize) + minSize; - this.speedX = Math.random() * speed * 2 - speed; - this.speedY = Math.random() * speed * 2 - speed; - this.opacity = Math.random() * 0.5 + 0.3; - } - - update() { - this.x += this.speedX; - this.y += this.speedY; - if (this.x > this.canvas.width) this.x = 0; - if (this.x < 0) this.x = this.canvas.width; - if (this.y > this.canvas.height) this.y = 0; - if (this.y < 0) this.y = this.canvas.height; - } - - draw() { - this.ctx.fillStyle = `${this.color}${this.opacity})`; - this.ctx.beginPath(); - this.ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); - this.ctx.fill(); - } -} - -/** - * 粒子动画 Hook - * 负责管理 Canvas 粒子动画效果 - */ -export function useParticleAnimation(config: ParticleConfig = {}) { - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const { - count = 100, - color = 'rgba(59, 130, 246, ', - minSize = 0.5, - maxSize = 2.5, - speed = 0.25, - } = config; - - // 设置画布尺寸 - const resizeCanvas = () => { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - }; - resizeCanvas(); - window.addEventListener('resize', resizeCanvas); - - const particles: Particle[] = []; - for (let i = 0; i < count; i++) { - particles.push(new Particle(canvas, ctx, maxSize, minSize, speed, color)); - } - - let animationId: number; - function animate() { - if (!ctx || !canvas) return; - ctx.clearRect(0, 0, canvas.width, canvas.height); - particles.forEach((particle) => { - particle.update(); - particle.draw(); - }); - animationId = requestAnimationFrame(animate); - } - animate(); - - return () => { - window.removeEventListener('resize', resizeCanvas); - cancelAnimationFrame(animationId); - }; - }, [config]); - - return canvasRef; -} diff --git a/plugins/pages/splashscreen/src/hooks/useSplashscreenEvents.ts b/plugins/pages/splashscreen/src/hooks/useSplashscreenEvents.ts deleted file mode 100644 index a5b20919..00000000 --- a/plugins/pages/splashscreen/src/hooks/useSplashscreenEvents.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { useEffect, useState } from 'react'; -import { listen } from '@tauri-apps/api/event'; -import type { SplashscreenState } from '../types'; - -/** - * Tauri 事件负载类型 - */ -interface TauriEventPayload { - payload?: { - text?: string; - message?: string; - percentage?: number; - error?: string; - error_message?: string; - }; -} - -/** - * Splashscreen 事件监听 Hook - * 负责监听后端事件并更新状态 - */ -export function useSplashscreenEvents() { - const [state, setState] = useState({ - loadingText: '初始化中...', - errorMessage: null, - connectionFailed: false, - showCloseButton: false, - progress: undefined, - isUpdating: false, - }); - - useEffect(() => { - let unsubscribeProgress: (() => void) | null = null; - let unsubscribeReady: (() => void) | null = null; - let unsubscribeError: (() => void) | null = null; - let unsubscribeConnectionFailed: (() => void) | null = null; - let unsubscribeUpdateDownloading: (() => void) | null = null; - let unsubscribeUpdateProgress: (() => void) | null = null; - let unsubscribeUpdateComplete: (() => void) | null = null; - let unsubscribeUpdateFailed: (() => void) | null = null; - let unsubscribeUpdatePartial: (() => void) | null = null; - - const setupListeners = async () => { - try { - unsubscribeProgress = await listen('splashscreen-progress', (event: TauriEventPayload) => { - const { text } = event.payload || {}; - if (text) { - setState((prev) => ({ ...prev, loadingText: text, errorMessage: null })); - } - }); - - unsubscribeReady = await listen('splashscreen-ready', async () => { - setState((prev) => ({ - ...prev, - loadingText: '加载完成', - progress: 100, - isUpdating: false, - })); - }); - - unsubscribeError = await listen('splashscreen-error', (event: TauriEventPayload) => { - const { message } = event.payload || {}; - setState((prev) => ({ - ...prev, - errorMessage: message || '加载过程中发生错误', - })); - }); - - unsubscribeConnectionFailed = await listen('splashscreen-connection-failed', () => { - setState((prev) => ({ - ...prev, - connectionFailed: true, - showCloseButton: true, - loadingText: '服务器连接失败,请尝试重新启动或重新下载', - })); - }); - - // 更新:下载开始 - unsubscribeUpdateDownloading = await listen('update_downloading', () => { - setState((prev) => ({ - ...prev, - isUpdating: true, - loadingText: '正在下载更新...', - errorMessage: null, - })); - }); - - // 更新:下载进度(已聚合的百分比) - unsubscribeUpdateProgress = await listen( - 'update_download_progress', - (event: TauriEventPayload) => { - const { percentage } = event.payload || {}; - const progressNumber = - typeof percentage === 'number' ? Math.max(0, Math.min(100, percentage)) : undefined; - setState((prev) => ({ - ...prev, - isUpdating: true, - progress: progressNumber ?? prev.progress, - loadingText: '正在下载更新...', - errorMessage: null, - })); - } - ); - - // 更新:下载完成 - unsubscribeUpdateComplete = await listen('update_download_complete', () => { - setState((prev) => ({ - ...prev, - isUpdating: false, - progress: 100, - loadingText: '更新下载完成,准备安装...', - })); - }); - - // 更新:下载失败或部分失败 - unsubscribeUpdateFailed = await listen( - 'update_download_failed', - (event: TauriEventPayload) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const payloadData = (event.payload as any) || {}; - const message = - payloadData?.error_message || payloadData?.error || '下载更新失败,继续当前版本'; - setState((prev) => ({ - ...prev, - isUpdating: false, - loadingText: '下载更新失败,继续当前版本', - errorMessage: message, - })); - } - ); - unsubscribeUpdatePartial = await listen( - 'update_download_partial', - (event: TauriEventPayload) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const payloadData = (event.payload as any) || {}; - const message = - payloadData?.error_message || '部分更新下载失败,已保留成功部分,继续当前版本'; - setState((prev) => ({ - ...prev, - isUpdating: false, - loadingText: '部分更新下载失败,继续当前版本', - errorMessage: message, - })); - } - ); - } catch (error) { - console.error('[SplashscreenEvents] Failed to register event listeners:', error); - } - }; - - setupListeners(); - - return () => { - unsubscribeProgress?.(); - unsubscribeReady?.(); - unsubscribeError?.(); - unsubscribeConnectionFailed?.(); - unsubscribeUpdateDownloading?.(); - unsubscribeUpdateProgress?.(); - unsubscribeUpdateComplete?.(); - unsubscribeUpdateFailed?.(); - unsubscribeUpdatePartial?.(); - }; - }, []); - - return state; -} diff --git a/plugins/pages/splashscreen/src/hooks/useSplashscreenInit.ts b/plugins/pages/splashscreen/src/hooks/useSplashscreenInit.ts deleted file mode 100644 index 28f16bc2..00000000 --- a/plugins/pages/splashscreen/src/hooks/useSplashscreenInit.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useEffect } from 'react'; -import { invoke } from '@/lib/tauri'; - -/** - * Splashscreen 初始化 Hook - * 负责检查应用状态 - */ -export function useSplashscreenInit() { - useEffect(() => { - const init = async () => { - try { - const appState = (await invoke('get_app_state')) as { is_initialized?: boolean }; - - if (appState?.is_initialized) { - await invoke('complete_and_show_main'); - } - } catch (error) { - console.error('[SplashscreenInit] Initialization failed:', error); - } - }; - - init(); - }, []); -} diff --git a/plugins/pages/splashscreen/src/hooks/useSplashscreenWindow.ts b/plugins/pages/splashscreen/src/hooks/useSplashscreenWindow.ts deleted file mode 100644 index 8c7a4291..00000000 --- a/plugins/pages/splashscreen/src/hooks/useSplashscreenWindow.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useCallback } from 'react'; -import { getCurrentWindow } from '@tauri-apps/api/window'; - -/** - * 窗口显示控制 Hook - * 负责在内容准备好后显示 splashscreen 窗口 - */ -export function useSplashscreenWindow() { - const showWindowWhenReady = useCallback(async () => { - try { - const splashWindow = getCurrentWindow(); - requestAnimationFrame(() => { - requestAnimationFrame(async () => { - setTimeout(async () => { - try { - await splashWindow.show(); - await splashWindow.setFocus(); - } catch (error) { - console.error('显示窗口失败:', error); - } - }, 100); - }); - }); - } catch (error) { - console.error('显示窗口失败:', error); - } - }, []); - - return { showWindowWhenReady }; -} diff --git a/plugins/pages/splashscreen/src/index.tsx b/plugins/pages/splashscreen/src/index.tsx deleted file mode 100644 index 5bcf81e8..00000000 --- a/plugins/pages/splashscreen/src/index.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import '../../../../src/index.css'; -import './styles/animations.css'; -import { useSplashscreenInit } from './hooks/useSplashscreenInit'; -import { useSplashscreenEvents } from './hooks/useSplashscreenEvents'; -import { useSplashscreenWindowDisplay } from './hooks/use-splashscreen-window-display'; -import { useSplashscreenClose } from './hooks/use-splashscreen-close'; -import { - SplashscreenBackground, - SplashscreenLogo, - SplashscreenContent, - SplashscreenLoadingText, - SplashscreenErrorMessage, - SplashscreenDecoration, -} from './components'; -import { SplashscreenCloseButton } from './components/splashscreen-close-button'; - -/** - * Splashscreen 页面主组件 - * 负责整合所有子组件和业务逻辑 - */ -const SplashscreenPage: React.FC = () => { - // 初始化应用状态检查 - useSplashscreenInit(); - - // 监听事件并获取状态 - const { loadingText, errorMessage, connectionFailed, showCloseButton, progress, isUpdating } = - useSplashscreenEvents(); - - // 窗口显示控制 - const { contentRef } = useSplashscreenWindowDisplay(); - - // 关闭窗口处理 - const { handleClose } = useSplashscreenClose(); - - return ( -
- {/* 背景(网格动画和光效) */} - - - {/* 右上角关闭按钮(仅在连接失败时显示) */} - {showCloseButton && } - - {/* 主要内容区域 */} -
- {/* Logo 区域 */} - - - {/* 内容区域(标题) */} - - - {/* 错误消息(仅在非连接失败错误时显示) */} - {errorMessage && !connectionFailed && } -
- - {/* 右下角加载文本 */} - - - {/* 装饰元素 */} - -
- ); -}; - -const splashscreenPlugin = { - id: 'splashscreen', - name: 'Splashscreen', - version: '1.0.0', - component: SplashscreenPage, - slots: [], -}; - -export default splashscreenPlugin; diff --git a/plugins/pages/splashscreen/src/styles/animations.css b/plugins/pages/splashscreen/src/styles/animations.css deleted file mode 100644 index 94319293..00000000 --- a/plugins/pages/splashscreen/src/styles/animations.css +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Splashscreen 动画样式定义 - */ - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@keyframes float-1 { - 0%, - 100% { - transform: translateY(0px) translateX(0px); - } - 50% { - transform: translateY(-10px) translateX(5px); - } -} - -@keyframes float-2 { - 0%, - 100% { - transform: translateY(0px) translateX(0px); - } - 50% { - transform: translateY(-8px) translateX(-5px); - } -} - -@keyframes float-3 { - 0%, - 100% { - transform: translateY(0px) translateX(0px); - } - 50% { - transform: translateY(-12px) translateX(8px); - } -} - -@keyframes float-4 { - 0%, - 100% { - transform: translateY(0px) translateX(0px); - } - 50% { - transform: translateY(-10px) translateX(-8px); - } -} - -@keyframes fade-in-up { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} diff --git a/plugins/pages/splashscreen/src/types/index.ts b/plugins/pages/splashscreen/src/types/index.ts deleted file mode 100644 index c21ae097..00000000 --- a/plugins/pages/splashscreen/src/types/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Splashscreen 插件类型定义 - */ - -export interface SplashscreenState { - loadingText: string; - progress?: number; // 0-100 的进度百分比 - errorMessage: string | null; - connectionFailed: boolean; - showCloseButton: boolean; - isUpdating?: boolean; // 是否正在执行更新 -} - -export interface SplashscreenEvents { - onProgress?: (text: string) => void; - onReady?: () => void; - onError?: (message: string) => void; -} - -export interface ParticleConfig { - count?: number; - color?: string; - minSize?: number; - maxSize?: number; - speed?: number; -} diff --git a/plugins/pages/splashscreen/tsconfig.json b/plugins/pages/splashscreen/tsconfig.json deleted file mode 100644 index f5b67230..00000000 --- a/plugins/pages/splashscreen/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "jsx": "react-jsx" - }, - "include": ["src/**/*"] -} diff --git a/plugins/pages/system-settings/src/api/users.ts b/plugins/pages/system-settings/src/api/users.ts deleted file mode 100644 index 6c72116a..00000000 --- a/plugins/pages/system-settings/src/api/users.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * 用户相关 API - */ -import { post, isSuccess } from '@/lib/request'; - -const API_ENDPOINTS = { - GET_CURRENT_USER: 'users/me', - UPDATE_USER: 'users/update', - UPDATE_PASSWORD: 'users/password', - VERIFY_PASSWORD: 'users/verify-password', -} as const; - -/** - * 当前用户信息响应(与后端 UserResponse 对应) - */ -export interface CurrentUserResponse { - uuid: string; - id: string; - nickname?: string; - email: string; - phone?: string; - avatar_hash?: string; - avatar_url?: string; - status: string; - created_at: string; - updated_at: string; - current_team?: { uuid: string; name: string; [key: string]: unknown }; - current_workspace?: { uuid: string; name: string; [key: string]: unknown }; -} - -/** - * 获取当前用户信息 - */ -export async function getCurrentUser(): Promise { - const result = await post(API_ENDPOINTS.GET_CURRENT_USER, {}); - - if (!isSuccess(result) || !result.data) { - return null; - } - - return result.data; -} - -/** - * 更新用户信息请求 - */ -export interface UpdateUserRequest { - nickname?: string; - phone?: string; - email?: string; -} - -/** - * 更新当前用户信息 - */ -export async function updateCurrentUser( - payload: UpdateUserRequest -): Promise<{ ok: boolean; message?: string }> { - const result = await post(API_ENDPOINTS.UPDATE_USER, payload); - - if (!isSuccess(result)) { - return { ok: false, message: result.message || '更新失败' }; - } - - return { ok: true }; -} - -/** - * 修改密码请求 - */ -export interface UpdatePasswordRequest { - old_password: string; - new_password: string; -} - -export interface VerifyPasswordRequest { - password: string; -} - -/** - * 修改密码 - */ -export async function updatePassword( - payload: UpdatePasswordRequest -): Promise<{ ok: boolean; message?: string }> { - const result = await post(API_ENDPOINTS.UPDATE_PASSWORD, payload); - - if (!isSuccess(result)) { - return { ok: false, message: result.message || '修改密码失败' }; - } - - return { ok: true }; -} - -/** - * 校验当前用户密码 - */ -export async function verifyCurrentUserPassword( - payload: VerifyPasswordRequest -): Promise<{ ok: boolean; valid: boolean; message?: string }> { - const result = await post<{ valid: boolean }>(API_ENDPOINTS.VERIFY_PASSWORD, payload); - - if (!isSuccess(result) || !result.data) { - return { ok: false, valid: false, message: result.message || '校验失败' }; - } - - return { ok: true, valid: result.data.valid }; -} diff --git a/plugins/pages/system-settings/src/components/account-panel.tsx b/plugins/pages/system-settings/src/components/account-panel.tsx index c712a25b..fadbc6fd 100644 --- a/plugins/pages/system-settings/src/components/account-panel.tsx +++ b/plugins/pages/system-settings/src/components/account-panel.tsx @@ -1,16 +1,6 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { - User, - Shield, - Phone, - Calendar, - MapPin, - Lock, - Timer, - KeyRound, - ChevronRight, -} from 'lucide-react'; +import { KeyRound, Lock, Shield, Timer, User } from 'lucide-react'; import { Switch } from '@/components/ui/switch'; import { Select, @@ -19,86 +9,31 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Skeleton } from '@/components/ui/skeleton'; import { SettingCard } from './setting-card'; import { SettingRow } from './setting-row'; -import { getCurrentUser, type CurrentUserResponse } from '../api/users'; -import { EditProfileDialog } from './edit-profile-dialog'; -import { ChangePasswordDialog } from './change-password-dialog'; import { getAccountSecuritySettings, setAccountSecuritySettings, LOCK_TIME_OPTIONS, + useAuthStore, } from '../../../../services/store/src'; -/** 格式化日期为 YYYY-MM-DD */ -function formatJoinDate(isoDate?: string): string { - if (!isoDate) return '—'; - try { - const d = new Date(isoDate); - return d.toISOString().slice(0, 10); - } catch { - return '—'; - } -} - -/** 手机号脱敏,如 138****8888 */ -function maskPhone(phone?: string): string { - if (!phone) return '—'; - if (phone.length <= 4) return '****'; - return phone.slice(0, 3) + '****' + phone.slice(-4); -} - -/** 从名称获取首字母,如 "张三" -> "张","John Doe" -> "JD" */ -function getInitials(name: string): string { - if (!name || name === '—') return '?'; - const trimmed = name.trim(); - if (!trimmed) return '?'; - const parts = trimmed.split(/\s+/); - if (parts.length >= 2) { - return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase().slice(0, 2); - } - return trimmed[0].toUpperCase(); -} - -/** - * 账户与安全面板 - */ +/** 本地用户资料与设备安全设置。 */ export const AccountPanel: React.FC = () => { const { t } = useTranslation('settings'); + const user = useAuthStore((state) => state.user); const [autoLockEnabled, setAutoLockEnabled] = useState(false); const [autoLockTime, setAutoLockTime] = useState(5); const [cleanDataOnExit, setCleanDataOnExit] = useState(false); const [securitySettingsLoaded, setSecuritySettingsLoaded] = useState(false); - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - const [editDialogOpen, setEditDialogOpen] = useState(false); - const [changePasswordDialogOpen, setChangePasswordDialogOpen] = useState(false); - - const fetchUser = useCallback(async () => { - setLoading(true); - try { - const data = await getCurrentUser(); - setUser(data); - } catch { - setUser(null); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void fetchUser(); - }, [fetchUser]); - useEffect(() => { let cancelled = false; - void getAccountSecuritySettings().then((s) => { + void getAccountSecuritySettings().then((settings) => { if (!cancelled) { - setAutoLockEnabled(s.autoLockEnabled); - setAutoLockTime(s.autoLockTime); - setCleanDataOnExit(s.cleanDataOnExit); + setAutoLockEnabled(settings.autoLockEnabled); + setAutoLockTime(settings.autoLockTime); + setCleanDataOnExit(settings.cleanDataOnExit); setSecuritySettingsLoaded(true); } }); @@ -113,9 +48,9 @@ export const AccountPanel: React.FC = () => { }, []); const handleAutoLockTimeChange = useCallback((value: string) => { - const num = Number(value); - setAutoLockTime(num); - void setAccountSecuritySettings({ autoLockTime: num }); + const minutes = Number(value); + setAutoLockTime(minutes); + void setAccountSecuritySettings({ autoLockTime: minutes }); }, []); const handleCleanDataOnExitChange = useCallback((checked: boolean) => { @@ -123,111 +58,32 @@ export const AccountPanel: React.FC = () => { void setAccountSecuritySettings({ cleanDataOnExit: checked }); }, []); - const avatarUrl = user?.avatar_url ?? ''; - const displayName = user?.nickname || user?.email?.split('@')[0] || '—'; - const displayEmail = user?.email || '—'; - const displayPhone = maskPhone(user?.phone); - const joinDate = formatJoinDate(user?.created_at); - const location = '中国'; - return (
- {/* 个人资料 */} - {loading ? ( - <> -
- -
- - -
- -
-
- {[1, 2, 3, 4].map((i) => ( -
- - -
- ))} -
- - ) : ( - <> -
-
- {avatarUrl ? ( - {t('accountProfile')} - ) : ( - - {getInitials(displayName)} - - )} -
-
-

{displayName}

-

{displayEmail}

-
- -
- -
- - - - - - - - - -
- - )} +
+
+ {user?.avatar || '🙂'} +
+
+

+ {user?.nickname || t('localUser')} +

+

{t('localAccountDesc')}

+
+
+
+ + + +
- - - - - {/* 安全设置 */} - - - { /> {autoLockEnabled && ( -
+
{t('lockTime')}
)} - + void; - onSuccess?: () => void; -} - -export function ChangePasswordDialog({ - open, - onOpenChange, - onSuccess, -}: ChangePasswordDialogProps) { - const { t } = useTranslation('settings'); - const [oldPassword, setOldPassword] = useState(''); - const [newPassword, setNewPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [submitting, setSubmitting] = useState(false); - - useEffect(() => { - if (open) { - setOldPassword(''); - setNewPassword(''); - setConfirmPassword(''); - } - }, [open]); - - const validate = (): boolean => { - if (!oldPassword.trim()) { - toast.error(t('changePasswordOldRequired') || '请输入原密码'); - return false; - } - if (!newPassword.trim()) { - toast.error(t('changePasswordNewRequired') || '请输入新密码'); - return false; - } - if (newPassword.length < MIN_PASSWORD_LENGTH) { - toast.error( - t('changePasswordMinLength', { count: MIN_PASSWORD_LENGTH }) || - `密码至少需要${MIN_PASSWORD_LENGTH}个字符` - ); - return false; - } - if (newPassword !== confirmPassword) { - toast.error(t('changePasswordMismatch') || '两次输入的新密码不一致'); - return false; - } - if (oldPassword === newPassword) { - toast.error(t('changePasswordSameAsOld') || '新密码不能与原密码相同'); - return false; - } - return true; - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!validate()) return; - - setSubmitting(true); - try { - const result = await updatePassword({ - old_password: oldPassword, - new_password: newPassword, - }); - - if (result.ok) { - toast.success(t('changePasswordSuccess') || '密码修改成功'); - onOpenChange(false); - onSuccess?.(); - } else { - toast.error(result.message || t('changePasswordFailed') || '修改密码失败'); - } - } catch { - toast.error(t('changePasswordFailed') || '修改密码失败'); - } finally { - setSubmitting(false); - } - }; - - return ( - -
-
- -
- - setOldPassword(e.target.value)} - placeholder={t('changePasswordOldPlaceholder')} - className="pl-9 h-9 text-sm" - disabled={submitting} - /> -
-
-
- -
- - setNewPassword(e.target.value)} - placeholder={t('changePasswordNewPlaceholder')} - className="pl-9 h-9 text-sm" - disabled={submitting} - /> -
-
-
- -
- - setConfirmPassword(e.target.value)} - placeholder={t('changePasswordConfirmPlaceholder')} - className="pl-9 h-9 text-sm" - disabled={submitting} - /> -
-
-
- - - - - -
- ); -} diff --git a/plugins/pages/system-settings/src/components/edit-profile-dialog.tsx b/plugins/pages/system-settings/src/components/edit-profile-dialog.tsx deleted file mode 100644 index 05249d14..00000000 --- a/plugins/pages/system-settings/src/components/edit-profile-dialog.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Loader2, User, X, Save } from 'lucide-react'; -import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { TextareaInput } from '@/components/textarea-input'; -import { toast } from 'sonner'; -import { updateCurrentUser, type CurrentUserResponse } from '../api/users'; - -interface EditProfileDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - user: CurrentUserResponse | null; - onSuccess: () => void; -} - -/** 严格邮箱格式:本地部分 + @ + 域名 + 至少2位顶级域名 */ -const EMAIL_REGEX = - /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/; -/** 中国大陆手机号:1 开头,第二位 3-9,共 11 位数字 */ -const PHONE_REGEX = /^1[3-9]\d{9}$/; - -function validateEmail(value: string): { valid: boolean; message?: string } { - const trimmed = value.trim(); - if (!trimmed) return { valid: false, message: 'editProfileEmailRequired' }; - if (trimmed.length > 254) return { valid: false, message: 'editProfileInvalidEmail' }; - if (!EMAIL_REGEX.test(trimmed)) return { valid: false, message: 'editProfileInvalidEmail' }; - return { valid: true }; -} - -function validatePhone(value: string): { valid: boolean; message?: string } { - const trimmed = value.trim(); - if (!trimmed) return { valid: true }; // 可选字段,空则通过 - const digits = trimmed.replace(/\D/g, ''); - if (digits.length !== 11 || !PHONE_REGEX.test(digits)) { - return { valid: false, message: 'editProfileInvalidPhone' }; - } - return { valid: true }; -} - -export function EditProfileDialog({ - open, - onOpenChange, - user, - onSuccess, -}: EditProfileDialogProps) { - const { t } = useTranslation('settings'); - const [nickname, setNickname] = useState(''); - const [phone, setPhone] = useState(''); - const [email, setEmail] = useState(''); - const [submitting, setSubmitting] = useState(false); - const [confirmOpen, setConfirmOpen] = useState(false); - - useEffect(() => { - if (open && user) { - setNickname(user.nickname ?? ''); - setPhone(user.phone ?? ''); - setEmail(user.email ?? ''); - } - }, [open, user]); - - const handleFormSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!user) return; - - const emailResult = validateEmail(email); - if (!emailResult.valid) { - toast.error(t(emailResult.message!) || '请输入有效的邮箱地址'); - return; - } - - const phoneResult = validatePhone(phone); - if (!phoneResult.valid) { - toast.error(t(phoneResult.message!) || '请输入有效的手机号'); - return; - } - - setConfirmOpen(true); - }; - - const handleConfirmSave = async () => { - if (!user) return; - - const emailResult = validateEmail(email); - if (!emailResult.valid) { - toast.error(t(emailResult.message!) || '请输入有效的邮箱地址'); - return; - } - const phoneResult = validatePhone(phone); - if (!phoneResult.valid) { - toast.error(t(phoneResult.message!) || '请输入有效的手机号'); - return; - } - - setSubmitting(true); - setConfirmOpen(false); - try { - const trimmedEmail = email.trim(); - const result = await updateCurrentUser({ - nickname: nickname.trim() || undefined, - phone: phone.trim() ? phone.trim().replace(/\D/g, '') : undefined, - email: trimmedEmail || undefined, - }); - - if (result.ok) { - toast.success(t('editProfileSuccess') || '资料更新成功'); - onOpenChange(false); - onSuccess(); - } else { - toast.error(result.message || t('editProfileFailed') || '更新失败'); - } - } catch { - toast.error(t('editProfileFailed') || '更新失败'); - } finally { - setSubmitting(false); - } - }; - - return ( - <> - -
-
- - setNickname(e.target.value)} - placeholder={t('nicknamePlaceholder')} - className="text-sm min-h-9" - disabled={submitting} - /> -
-
- - setPhone(e.target.value)} - placeholder={t('phonePlaceholder')} - className="text-sm min-h-9" - disabled={submitting} - /> -
-
- - setEmail(e.target.value)} - placeholder={t('emailPlaceholder')} - className="text-sm min-h-9" - disabled={submitting} - /> -
-
- - - - - -
- - - - - - - - - ); -} diff --git a/plugins/pages/system-settings/src/components/storage-panel.tsx b/plugins/pages/system-settings/src/components/storage-panel.tsx index 972cc764..82e31d9c 100644 --- a/plugins/pages/system-settings/src/components/storage-panel.tsx +++ b/plugins/pages/system-settings/src/components/storage-panel.tsx @@ -1,58 +1,78 @@ -import { useState, useEffect, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - HardDrive, - FolderOpen, - Download, - RefreshCcw, - FlaskConical, - Database, - FileBox, - FileText, - Copy, - ExternalLink, - Settings, - RotateCcw, - Loader2, - X, -} from 'lucide-react'; -import { getVersion } from '@tauri-apps/api/app'; -import { invoke } from '@/lib/tauri'; -import { relaunch } from '@tauri-apps/plugin-process'; -import { toast } from 'sonner'; -import { Switch } from '@/components/ui/switch'; +import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + HardDrive, + FolderOpen, + Download, + RefreshCcw, + Database, + FileBox, + FileText, + Copy, + ExternalLink, + Settings, + RotateCcw, + Loader2, + X, +} from 'lucide-react'; +import { getVersion } from '@tauri-apps/api/app'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; +import { checkForAppUpdate, openAppUpdateDialog } from '@/lib/app-updater'; import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; import { SettingCard } from './setting-card'; -import { SettingRow } from './setting-row'; import { StoragePanelSkeleton } from './storage-panel-skeleton'; -import { - getStorageSettings, - setStorageSettings, -} from '../../../../services/store/src'; -import { - getStorageDefaultPaths, - getDirectorySizes, -} from '../api/storage'; +import { getStorageSettings, setStorageSettings } from '../../../../services/store/src'; +import { getStorageDefaultPaths, getDirectorySizes } from '../api/storage'; import { openPath } from '@tauri-apps/plugin-opener'; import { open as openFolderDialog } from '@tauri-apps/plugin-dialog'; import type { DirectorySizeCache, StoragePathKey } from '../../../../services/store/src'; - -interface StorageItemDef { - id: string; - key: StoragePathKey | 'profilesPath' | 'downloadsPath'; - nameKey: string; - descKey: string; - icon: React.ElementType; - color: string; - readonly?: boolean; // 只读目录,不允许用户配置 -} - + +interface StorageItemDef { + id: string; + key: StoragePathKey | 'profilesPath' | 'downloadsPath'; + nameKey: string; + descKey: string; + icon: React.ElementType; + color: string; + readonly?: boolean; // 只读目录,不允许用户配置 +} + const STORAGE_ITEM_DEFS: StorageItemDef[] = [ - { id: 'profiles', key: 'profilesPath', nameKey: 'storageProfiles', descKey: 'storageProfilesDesc', icon: Database, color: 'bg-blue-500', readonly: true }, - { id: 'cache', key: 'cachePath', nameKey: 'storageCache', descKey: 'storageCacheDesc', icon: HardDrive, color: 'bg-amber-500' }, - { id: 'logs', key: 'logsPath', nameKey: 'storageLogs', descKey: 'storageLogsDesc', icon: FileText, color: 'bg-emerald-500' }, - { id: 'downloads', key: 'downloadsPath', nameKey: 'storageDownloads', descKey: 'storageDownloadsDesc', icon: FileBox, color: 'bg-purple-500', readonly: true }, + { + id: 'profiles', + key: 'profilesPath', + nameKey: 'storageProfiles', + descKey: 'storageProfilesDesc', + icon: Database, + color: 'bg-blue-500', + readonly: true, + }, + { + id: 'cache', + key: 'cachePath', + nameKey: 'storageCache', + descKey: 'storageCacheDesc', + icon: HardDrive, + color: 'bg-amber-500', + }, + { + id: 'logs', + key: 'logsPath', + nameKey: 'storageLogs', + descKey: 'storageLogsDesc', + icon: FileText, + color: 'bg-emerald-500', + }, + { + id: 'downloads', + key: 'downloadsPath', + nameKey: 'storageDownloads', + descKey: 'storageDownloadsDesc', + icon: FileBox, + color: 'bg-purple-500', + readonly: true, + }, ]; const EMPTY_SIZES = STORAGE_ITEM_DEFS.map(() => 0); @@ -63,27 +83,25 @@ function isDirectorySizeCacheValid( ): cache is DirectorySizeCache { return Boolean( cache && - cache.paths.length === paths.length && - cache.sizes.length === paths.length && - cache.paths.every((path, index) => path === paths[index]) + cache.paths.length === paths.length && + cache.sizes.length === paths.length && + cache.paths.every((path, index) => path === paths[index]) ); } - -/** - * 存储与更新面板 - */ -export const StoragePanel: React.FC = () => { - const { t } = useTranslation('settings'); - const [betaChannel, setBetaChannel] = useState(true); - const [storageSettingsLoaded, setStorageSettingsLoaded] = useState(false); - const [appVersion, setAppVersion] = useState(null); - - const [defaultPaths, setDefaultPaths] = useState<{ - app_base: string; - profiles: string; - cache: string; - logs: string; - downloads: string; + +/** + * 存储与更新面板 + */ +export const StoragePanel: React.FC = () => { + const { t } = useTranslation('settings'); + const [appVersion, setAppVersion] = useState(null); + + const [defaultPaths, setDefaultPaths] = useState<{ + app_base: string; + profiles: string; + cache: string; + logs: string; + downloads: string; } | null>(null); const [paths, setPaths] = useState([]); const [sizes, setSizes] = useState(EMPTY_SIZES); @@ -93,49 +111,29 @@ export const StoragePanel: React.FC = () => { const [sizesUpdatedAt, setSizesUpdatedAt] = useState(null); const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false); const [restoreSubmitting, setRestoreSubmitting] = useState(false); - - const [hasUpdate, setHasUpdate] = useState(false); - const [updateChecking, setUpdateChecking] = useState(false); - const [updateChecked, setUpdateChecked] = useState(false); - - useEffect(() => { - let cancelled = false; - void getStorageSettings() - .then((s) => { - if (!cancelled) { - setBetaChannel(s.betaChannel); - setStorageSettingsLoaded(true); - } - }) - .catch(() => { - if (!cancelled) setStorageSettingsLoaded(true); - }); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - let cancelled = false; - void getVersion() - .then((v) => { - if (!cancelled) setAppVersion(v); - }) - .catch(() => { - if (!cancelled) setAppVersion(null); - }); - return () => { - cancelled = true; - }; - }, []); - + + const [hasUpdate, setHasUpdate] = useState(false); + const [updateChecking, setUpdateChecking] = useState(false); + const [updateChecked, setUpdateChecked] = useState(false); + + useEffect(() => { + let cancelled = false; + void getVersion() + .then((v) => { + if (!cancelled) setAppVersion(v); + }) + .catch(() => { + if (!cancelled) setAppVersion(null); + }); + return () => { + cancelled = true; + }; + }, []); + const loadPathsAndSizes = useCallback(async () => { setPathsLoading(true); try { - const [defs, storage] = await Promise.all([ - getStorageDefaultPaths(), - getStorageSettings(), - ]); + const [defs, storage] = await Promise.all([getStorageDefaultPaths(), getStorageSettings()]); setDefaultPaths(defs); const resolvedPaths = STORAGE_ITEM_DEFS.map((d) => { @@ -182,92 +180,84 @@ export const StoragePanel: React.FC = () => { setSizesRefreshing(false); } }, []); - - useEffect(() => { - void loadPathsAndSizes(); - }, [loadPathsAndSizes]); - - const handleBetaChannelChange = useCallback((checked: boolean) => { - setBetaChannel(checked); - void setStorageSettings({ betaChannel: checked }); - }, []); - - const totalSizeBytes = sizes.reduce((a, b) => a + b, 0); - - const handleCopyPath = (path: string) => { - navigator.clipboard.writeText(path); - }; - - const handleOpenFolder = useCallback((path: string) => { - void openPath(path); - }, []); - - const handleOpenAppFolder = useCallback(() => { - if (defaultPaths?.app_base) { - void openPath(defaultPaths.app_base); - } - }, [defaultPaths?.app_base]); - - const handleSetPath = useCallback( - async (def: StorageItemDef) => { - const selected = await openFolderDialog({ - directory: true, - multiple: false, - }); - if (selected && typeof selected === 'string') { - await setStorageSettings({ [def.key]: selected }); - await loadPathsAndSizes(); - } - }, - [loadPathsAndSizes] - ); - - const handleOpenRestoreConfirm = useCallback(() => { - setRestoreConfirmOpen(true); - }, []); - - const handleCheckUpdate = useCallback(async () => { - setUpdateChecking(true); - try { - const available = await invoke('check_update_available'); - setHasUpdate(available); - setUpdateChecked(true); - if (available) { - toast.success(t('updateAvailable')); - } else { - toast.success(t('alreadyLatest')); - } - } catch (e) { - toast.error(t('checkUpdateFailed') || '检查更新失败'); - setHasUpdate(false); - setUpdateChecked(true); - } finally { - setUpdateChecking(false); - } - }, [t]); - - const handleUpdateNow = useCallback(async () => { - try { - await relaunch(); - } catch (e) { - toast.error(t('updateNowFailed') || '立即更新失败'); - } - }, [t]); - - const handleConfirmRestore = useCallback(async () => { - setRestoreSubmitting(true); - setRestoreConfirmOpen(false); - try { - await setStorageSettings({ - cachePath: undefined, - logsPath: undefined, - }); - await loadPathsAndSizes(); - } finally { - setRestoreSubmitting(false); - } - }, [loadPathsAndSizes]); - + + useEffect(() => { + void loadPathsAndSizes(); + }, [loadPathsAndSizes]); + + const totalSizeBytes = sizes.reduce((a, b) => a + b, 0); + + const handleCopyPath = (path: string) => { + navigator.clipboard.writeText(path); + }; + + const handleOpenFolder = useCallback((path: string) => { + void openPath(path); + }, []); + + const handleOpenAppFolder = useCallback(() => { + if (defaultPaths?.app_base) { + void openPath(defaultPaths.app_base); + } + }, [defaultPaths?.app_base]); + + const handleSetPath = useCallback( + async (def: StorageItemDef) => { + const selected = await openFolderDialog({ + directory: true, + multiple: false, + }); + if (selected && typeof selected === 'string') { + await setStorageSettings({ [def.key]: selected }); + await loadPathsAndSizes(); + } + }, + [loadPathsAndSizes] + ); + + const handleOpenRestoreConfirm = useCallback(() => { + setRestoreConfirmOpen(true); + }, []); + + const handleCheckUpdate = useCallback(async () => { + setUpdateChecking(true); + try { + const update = await checkForAppUpdate({ force: true }); + setHasUpdate(Boolean(update)); + setUpdateChecked(true); + if (update) { + toast.success(t('updateAvailable')); + openAppUpdateDialog(); + } else { + toast.success(t('alreadyLatest')); + } + } catch { + toast.error(t('checkUpdateFailed') || '检查更新失败'); + setHasUpdate(false); + setUpdateChecked(true); + } finally { + setUpdateChecking(false); + } + }, [t]); + + const handleUpdateNow = useCallback(() => { + openAppUpdateDialog(); + }, []); + + const handleConfirmRestore = useCallback(async () => { + setRestoreSubmitting(true); + setRestoreConfirmOpen(false); + try { + await setStorageSettings({ + cachePath: undefined, + logsPath: undefined, + }); + await loadPathsAndSizes(); + } finally { + setRestoreSubmitting(false); + } + }, [loadPathsAndSizes]); + const formatSize = (bytes: number) => { const mb = bytes / (1024 * 1024); if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; @@ -394,118 +384,110 @@ export const StoragePanel: React.FC = () => {
)} - - {/* 快捷操作 */} + + {/* 快捷操作 */}
- - -
-
- - {/* 恢复默认确认弹窗 */} - - - - - - - - {/* 版本更新 */} - - - - - - {/* 当前版本 */} -
-
-

{t('currentVersion')}

-

- {appVersion != null ? `v${appVersion}` : '—'} -

-
- {hasUpdate ? ( + + +
+
+ + {/* 恢复默认确认弹窗 */} + + + + + + + + {/* 版本更新 */} + + {/* 当前版本 */} +
+
+

{t('currentVersion')}

+

+ {appVersion != null ? `v${appVersion}` : '—'} +

+
+ {hasUpdate ? ( - ) : ( + + {t('updateNow')} + + ) : ( - )} -
-
-
- ); -}; + {updateChecking ? ( + <> + + {t('checking')} + + ) : ( + <> + + {updateChecked ? t('alreadyLatest') : t('checkUpdate')} + + )} + + )} +
+ +
+ ); +}; diff --git a/plugins/pages/system-settings/src/i18n/resources.ts b/plugins/pages/system-settings/src/i18n/resources.ts index 63383d9c..55f07e75 100644 --- a/plugins/pages/system-settings/src/i18n/resources.ts +++ b/plugins/pages/system-settings/src/i18n/resources.ts @@ -19,6 +19,11 @@ export const settingsResources = { // Account Panel accountProfile: '个人资料', accountSecurity: '安全设置', + localUser: '本地用户', + localAccountDesc: '资料仅保存在当前设备', + localPassword: '本地密码', + passwordProtected: '已设置密码保护', + passwordlessAccount: '未设置密码,可直接进入', clickToChangeAvatar: '点击更换头像', editProfile: '编辑资料', editProfileDesc: '修改您的个人资料信息', @@ -226,6 +231,11 @@ export const settingsResources = { // Account Panel accountProfile: 'Profile', accountSecurity: 'Security', + localUser: 'Local User', + localAccountDesc: 'Profile data is stored only on this device', + localPassword: 'Local Password', + passwordProtected: 'Password protected', + passwordlessAccount: 'No password; direct entry enabled', clickToChangeAvatar: 'Click to change avatar', editProfile: 'Edit Profile', editProfileDesc: 'Update your profile information', diff --git a/plugins/pages/team/src/api/index.ts b/plugins/pages/team/src/api/index.ts index 6f01cc20..6d8a529e 100644 --- a/plugins/pages/team/src/api/index.ts +++ b/plugins/pages/team/src/api/index.ts @@ -4,7 +4,7 @@ import { post, isSuccess } from '@/lib/request'; import type { ListTeamMembersRequest, - InviteMemberRequest, + AddMemberRequest, UpdateMemberRoleRequest, RemoveMemberRequest, BatchRemoveMembersRequest, @@ -12,17 +12,12 @@ import type { GetTeamRequest, CreateTeamRequest, UpdateTeamRequest, - CancelInviteRequest, - AcceptInvitationRequest, - RejectInvitationRequest, InviteResponse, TeamMemberListResponse, TeamMemberDto, TeamDto, TeamListResponse, - TeamInvitationDto, - CreateResponse, - AcceptInvitationResponse, + CreateResponse, TeamMember, } from './index.types'; @@ -33,7 +28,7 @@ export * from './index.types'; export const API_ENDPOINTS = { // 团队成员 LIST_TEAM_MEMBERS: 'teams/members', - INVITE_MEMBER: 'teams/invite', + ADD_MEMBER: 'teams/member/add', UPDATE_MEMBER_ROLE: 'teams/member/role', REMOVE_MEMBER: 'teams/member/remove', @@ -45,11 +40,6 @@ export const API_ENDPOINTS = { SWITCH_TEAM: 'teams/switch', LEAVE_TEAM: 'teams/leave', - // 邀请管理 - GET_PENDING_INVITATIONS: 'teams/invitations', - CANCEL_INVITATION: 'teams/invitation/cancel', - ACCEPT_INVITATION: 'teams/invitation/accept', - REJECT_INVITATION: 'teams/invitation/reject', } as const; // ============ 数据转换 ============ @@ -63,7 +53,6 @@ function transformTeamMemberDto(dto: TeamMemberDto): TeamMember { return { id: memberId, name: dto.name || '', - email: dto.email || '', avatar: dto.avatar, role: (dto.role as TeamMember['role']) || 'viewer', status: (dto.status as TeamMember['status']) || 'active', @@ -109,8 +98,8 @@ export async function listTeamMembers( /** * 邀请成员 */ -export async function inviteMember(request: InviteMemberRequest): Promise { - const result = await post(API_ENDPOINTS.INVITE_MEMBER, request); +export async function addMember(request: AddMemberRequest): Promise { + const result = await post(API_ENDPOINTS.ADD_MEMBER, request); if (!isSuccess(result)) { throw new Error(result.message || '邀请成员失败'); } @@ -207,53 +196,9 @@ export async function switchTeam(teamUuid: string): Promise { /** * 退出团队 */ -export async function leaveTeam(): Promise { +export async function leaveTeam(): Promise { const result = await post(API_ENDPOINTS.LEAVE_TEAM, {}); if (!isSuccess(result)) { throw new Error(result.message || '退出团队失败'); - } -} - -// ============ 邀请管理 API ============ - -/** - * 获取待处理的邀请列表 - */ -export async function getPendingInvitations(): Promise { - const result = await post(API_ENDPOINTS.GET_PENDING_INVITATIONS, {}); - if (!isSuccess(result)) { - throw new Error(result.message || '获取邀请列表失败'); - } - return result.data! || []; -} - -/** - * 取消邀请 - */ -export async function cancelInvitation(request: CancelInviteRequest): Promise { - const result = await post(API_ENDPOINTS.CANCEL_INVITATION, request); - if (!isSuccess(result)) { - throw new Error(result.message || '取消邀请失败'); - } -} - -/** - * 接受邀请 - */ -export async function acceptInvitation(request: AcceptInvitationRequest): Promise { - const result = await post(API_ENDPOINTS.ACCEPT_INVITATION, request); - if (!isSuccess(result)) { - throw new Error(result.message || '接受邀请失败'); - } - return result.data!.team_uuid; -} - -/** - * 拒绝邀请 - */ -export async function rejectInvitation(request: RejectInvitationRequest): Promise { - const result = await post(API_ENDPOINTS.REJECT_INVITATION, request); - if (!isSuccess(result)) { - throw new Error(result.message || '拒绝邀请失败'); - } -} + } +} diff --git a/plugins/pages/team/src/api/index.types.ts b/plugins/pages/team/src/api/index.types.ts index 94531073..4cde7d6c 100644 --- a/plugins/pages/team/src/api/index.types.ts +++ b/plugins/pages/team/src/api/index.types.ts @@ -18,13 +18,13 @@ export interface ListTeamMembersRequest { }; } -export interface InviteMemberRequest { - email: string; +export interface AddMemberRequest { + user_uuid: string; role: string; } -export interface InviteResponse { - invitation_uuid: string; +export interface InviteResponse { + member_uuid: string; } export interface UpdateMemberRoleRequest { @@ -61,22 +61,10 @@ export interface UpdateTeamRequest { avatar_hash?: string; } -export interface CancelInviteRequest { - invitation_uuid: string; -} - -export interface AcceptInvitationRequest { - token: string; -} - -export interface RejectInvitationRequest { - token: string; -} - // ============ 响应类型 ============ -export interface TeamMemberListResponse { - items: TeamMemberDto[]; +export interface TeamMemberListResponse { + items: TeamMember[]; total: number; page: number; page_size: number; @@ -132,25 +120,6 @@ export interface TeamListResponse { teams: TeamItem[]; } -export interface TeamInvitationDto { - id: number; - uuid: string; - team_uuid: string; - email: string; - role: string; - invited_by: string; - token: string; - expires_at: string; - status: string; - accepted_at?: string; - created_at: string; - updated_at: string; -} - -export interface CreateResponse { - uuid: string; -} - -export interface AcceptInvitationResponse { - team_uuid: string; -} +export interface CreateResponse { + uuid: string; +} diff --git a/plugins/pages/team/src/components/team-invite-dialog.tsx b/plugins/pages/team/src/components/team-invite-dialog.tsx index 26a6ae89..553488fb 100644 --- a/plugins/pages/team/src/components/team-invite-dialog.tsx +++ b/plugins/pages/team/src/components/team-invite-dialog.tsx @@ -1,214 +1,129 @@ -import { useState, useEffect, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Loader2, UserPlus, Mail, X } from 'lucide-react'; -import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { TextareaInput } from '@/components/textarea-input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { useTeamDialogStore } from '../stores'; -import type { TeamMember } from '../types'; -import { cn } from '@/lib/utils'; - -interface TeamInviteDialogProps { - open: boolean; - email: string; - role: TeamMember['role']; - submitting: boolean; - onOpenChange: (open: boolean) => void; - onEmailChange: (email: string) => void; - onRoleChange: (role: TeamMember['role']) => void; - onSubmit: () => void; -} - -// 邮箱格式验证(要求后缀必须是小写,如 .com, .org, .net 等) -function isValidEmail(email: string): boolean { - // 正则表达式要求: - // - 用户名部分:一个或多个非空白、非@字符 - // - @ 符号 - // - 域名部分:一个或多个非空白、非@字符,至少包含一个点 - // - TLD(顶级域名):必须是小写字母,至少2个字符 - const emailRegex = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/; - return emailRegex.test(email); -} - -/** - * 邀请成员对话框组件 - */ -export const TeamInviteDialog: React.FC = ({ - open, - email, - role, - submitting, - onOpenChange, - onEmailChange, - onRoleChange, - onSubmit, -}) => { - const { t } = useTranslation('team'); - const dialogStore = useTeamDialogStore(); - const [emailError, setEmailError] = useState(null); - const [touched, setTouched] = useState(false); - const emailInputRef = useRef(null); - - // 当对话框打开时,聚焦邮箱输入框 - useEffect(() => { - if (open && emailInputRef.current) { - // 延迟聚焦,确保对话框动画完成 - setTimeout(() => { - emailInputRef.current?.focus(); - }, 100); - } - }, [open]); - - // 邮箱验证 - useEffect(() => { - if (touched && email) { - if (!isValidEmail(email)) { - setEmailError('请输入有效的邮箱地址'); - } else { - setEmailError(null); - } - } else { - setEmailError(null); - } - }, [email, touched]); - - const handleEmailChange = (value: string) => { - onEmailChange(value); - if (!touched) { - setTouched(true); - } - }; - - const handleSubmit = () => { - if (!email.trim()) { - setTouched(true); - setEmailError('请输入邮箱地址'); - emailInputRef.current?.focus(); - return; - } - if (!isValidEmail(email)) { - setTouched(true); - setEmailError('请输入有效的邮箱地址'); - emailInputRef.current?.focus(); - return; - } - onSubmit(); - }; - - const handleClose = (open: boolean) => { - onOpenChange(open); - if (!open) { - dialogStore.closeInviteDialog(); - // 重置状态 - setTouched(false); - setEmailError(null); - } - }; - - return ( - -
- {/* 邮箱输入 */} -
- -
- - handleEmailChange(e.target.value)} - placeholder={t('dialog.invite.emailPlaceholder') || 'user@example.com'} - aria-invalid={!!emailError} - className={cn( - 'py-[2px]', - emailError ? 'pl-9 border-destructive focus-visible:ring-destructive/50' : 'pl-9' - )} - disabled={submitting} - onKeyDown={(e) => { - if (e.key === 'Enter' && !emailError && email.trim() && !submitting) { - // TextareaInput 是 textarea,默认 Enter 会换行;这里阻止默认行为并提交 - e.preventDefault(); - handleSubmit(); - } - }} - /> -
- {emailError &&

{emailError}

} -
- - {/* 角色选择 */} -
- - -
-
- - - - - -
- ); -}; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2, UserPlus, X } from 'lucide-react'; +import { FormattedDialog, FormattedDialogFooter } from '@/components/formatted-dialog'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { invoke } from '@/lib/tauri'; +import { useAuthStore } from '../../../../services/store/src'; +import { useTeamDialogStore } from '../stores'; +import type { TeamMember } from '../types'; + +interface LocalUserOption { + uuid: string; + nickname: string; + avatar: string; +} + +interface TeamInviteDialogProps { + open: boolean; + userUuid: string; + role: TeamMember['role']; + submitting: boolean; + onOpenChange: (open: boolean) => void; + onUserChange: (userUuid: string) => void; + onRoleChange: (role: TeamMember['role']) => void; + onSubmit: () => void; +} + +export const TeamInviteDialog: React.FC = ({ + open, + userUuid, + role, + submitting, + onOpenChange, + onUserChange, + onRoleChange, + onSubmit, +}) => { + const { t } = useTranslation('team'); + const currentUserUuid = useAuthStore((state) => state.user?.uuid); + const dialogStore = useTeamDialogStore(); + const [users, setUsers] = useState([]); + const [loadingUsers, setLoadingUsers] = useState(false); + + useEffect(() => { + if (!open) return; + setLoadingUsers(true); + invoke('list_local_users') + .then((items) => setUsers(items.filter((user) => user.uuid !== currentUserUuid))) + .finally(() => setLoadingUsers(false)); + }, [currentUserUuid, open]); + + const handleClose = (nextOpen: boolean) => { + onOpenChange(nextOpen); + if (!nextOpen) dialogStore.closeInviteDialog(); + }; + + return ( + +
+
+ + +
+ +
+ + +
+
+ + + + + +
+ ); +}; diff --git a/plugins/pages/team/src/components/team-table-row.tsx b/plugins/pages/team/src/components/team-table-row.tsx index 75ecacb7..3a1a54fb 100644 --- a/plugins/pages/team/src/components/team-table-row.tsx +++ b/plugins/pages/team/src/components/team-table-row.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { Crown, Shield, Pencil, Eye, MoreVertical, Trash2, Mail } from 'lucide-react'; +import { Crown, Shield, Pencil, Eye, MoreVertical, Trash2 } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, @@ -20,7 +20,6 @@ import { export interface TeamMember { id: string; name: string; - email: string; avatar?: string; role: 'owner' | 'admin' | 'editor' | 'viewer'; status: 'active' | 'pending' | 'inactive'; @@ -120,7 +119,7 @@ export function TeamTableRow({ } }; - const initials = member.name.split('').slice(0, 2).join('').toUpperCase(); + const initials = member.name.split('').slice(0, 2).join('').toUpperCase(); const roleOptions: { value: TeamMember['role']; icon: React.ReactNode }[] = [ { value: 'admin', icon: }, @@ -141,14 +140,10 @@ export function TeamTableRow({
- {initials} + {member.avatar || initials}
{member.name}
-
- - {member.email} -
diff --git a/plugins/pages/team/src/hooks/use-team-handlers.ts b/plugins/pages/team/src/hooks/use-team-handlers.ts index edb0588d..890058ae 100644 --- a/plugins/pages/team/src/hooks/use-team-handlers.ts +++ b/plugins/pages/team/src/hooks/use-team-handlers.ts @@ -14,23 +14,23 @@ interface UseTeamHandlersParams { export function useTeamHandlers({ operations, onRefresh }: UseTeamHandlersParams) { const dialogStore = useTeamDialogStore(); - // 邀请成员 + // 添加本地成员 const handleInvite = () => { dialogStore.openInviteDialog(); }; const handleSubmitInvite = async () => { - if (!dialogStore.inviteEmail.trim()) { - toast.warning('请输入邮箱地址'); + if (!dialogStore.inviteUserUuid) { + toast.warning('请选择本地用户'); return; } try { - await operations.inviteMember(dialogStore.inviteEmail, dialogStore.inviteRole); + await operations.addMember(dialogStore.inviteUserUuid, dialogStore.inviteRole); dialogStore.closeInviteDialog(); - toast.success('邀请已发送'); + toast.success('本地用户已加入团队'); await onRefresh(); } catch (e) { - toast.error(e instanceof Error ? e.message : '邀请成员失败'); + toast.error(e instanceof Error ? e.message : '添加成员失败'); } }; diff --git a/plugins/pages/team/src/hooks/use-team-operations.ts b/plugins/pages/team/src/hooks/use-team-operations.ts index 79b632cf..092f63f0 100644 --- a/plugins/pages/team/src/hooks/use-team-operations.ts +++ b/plugins/pages/team/src/hooks/use-team-operations.ts @@ -1,10 +1,10 @@ import { useState } from 'react'; -import { inviteMember, updateMemberRole, removeMember, batchRemoveMembers } from '../api'; +import { addMember as addLocalMember, updateMemberRole, removeMember, batchRemoveMembers } from '../api'; import type { TeamMember } from '../types'; export interface UseTeamOperationsReturn { submitting: boolean; - inviteMember: (email: string, role: TeamMember['role']) => Promise<{ invitationUuid: string }>; + addMember: (userUuid: string, role: TeamMember['role']) => Promise<{ memberUuid: string }>; deleteMember: (memberUuid: string) => Promise; batchDeleteMembers: (memberUuids: string[]) => Promise; changeMemberRole: (memberUuid: string, newRole: TeamMember['role']) => Promise; @@ -16,15 +16,14 @@ export interface UseTeamOperationsReturn { export function useTeamOperations(): UseTeamOperationsReturn { const [submitting, setSubmitting] = useState(false); - // 邀请成员(单个) - const inviteMemberOp = async (email: string, role: TeamMember['role']) => { - if (!email.trim()) { - throw new Error('请输入邮箱地址'); + const addMember = async (userUuid: string, role: TeamMember['role']) => { + if (!userUuid) { + throw new Error('请选择本地用户'); } setSubmitting(true); try { - const res = await inviteMember({ email, role }); - return { invitationUuid: res.invitation_uuid }; + const res = await addLocalMember({ user_uuid: userUuid, role }); + return { memberUuid: res.member_uuid }; } finally { setSubmitting(false); } @@ -62,7 +61,7 @@ export function useTeamOperations(): UseTeamOperationsReturn { return { submitting, - inviteMember: inviteMemberOp, + addMember, deleteMember, batchDeleteMembers, changeMemberRole, diff --git a/plugins/pages/team/src/i18n/resources.ts b/plugins/pages/team/src/i18n/resources.ts index c27a52ba..af8a5f6d 100644 --- a/plugins/pages/team/src/i18n/resources.ts +++ b/plugins/pages/team/src/i18n/resources.ts @@ -62,15 +62,17 @@ export const teamResources = { }, dialog: { invite: { - title: '邀请团队成员', - description: '发送邀请邮件给新成员', - email: '邮箱地址', - emailPlaceholder: 'user@example.com', + title: '添加团队成员', + description: '从本机用户中选择一名成员', + localDescription: '选择已在本机创建的用户并设置其团队角色', + localUser: '本地用户', + localUserPlaceholder: '请选择本地用户', role: '角色', rolePlaceholder: '选择角色', cancel: '取消', - submit: '发送邀请', - submitting: '发送中...', + addLocalUser: '加入团队', + submit: '加入团队', + submitting: '添加中...', }, delete: { title: '确认移除成员', @@ -183,15 +185,17 @@ export const teamResources = { }, dialog: { invite: { - title: 'Invite Team Member', - description: 'Send an invitation email to new member', - email: 'Email Address', - emailPlaceholder: 'user@example.com', + title: 'Add Team Member', + description: 'Choose a member from local users', + localDescription: 'Select a user created on this device and assign a team role', + localUser: 'Local User', + localUserPlaceholder: 'Select a local user', role: 'Role', rolePlaceholder: 'Select role', cancel: 'Cancel', - submit: 'Send Invitation', - submitting: 'Sending...', + addLocalUser: 'Add to Team', + submit: 'Add to Team', + submitting: 'Adding...', }, delete: { title: 'Confirm Remove Member', diff --git a/plugins/pages/team/src/index.tsx b/plugins/pages/team/src/index.tsx index c19ca2e1..969647f6 100644 --- a/plugins/pages/team/src/index.tsx +++ b/plugins/pages/team/src/index.tsx @@ -89,11 +89,11 @@ const TeamPage: React.FC = () => { {/* 邀请成员对话框 */} diff --git a/plugins/pages/team/src/stores/dialogs/dialog-store.ts b/plugins/pages/team/src/stores/dialogs/dialog-store.ts index a9ebdac3..f6f06e99 100644 --- a/plugins/pages/team/src/stores/dialogs/dialog-store.ts +++ b/plugins/pages/team/src/stores/dialogs/dialog-store.ts @@ -4,7 +4,7 @@ import type { TeamMember, Team } from '../../types'; interface DialogState { // 邀请成员对话框 inviteDialogOpen: boolean; - inviteEmail: string; + inviteUserUuid: string; inviteRole: TeamMember['role']; // 删除成员对话框 @@ -28,7 +28,7 @@ interface DialogState { interface DialogActions { // 邀请成员对话框 setInviteDialogOpen: (open: boolean) => void; - setInviteEmail: (email: string) => void; + setInviteUserUuid: (userUuid: string) => void; setInviteRole: (role: TeamMember['role']) => void; openInviteDialog: () => void; closeInviteDialog: () => void; @@ -66,7 +66,7 @@ interface DialogActions { export const useTeamDialogStore = create((set) => ({ // 初始状态 inviteDialogOpen: false, - inviteEmail: '', + inviteUserUuid: '', inviteRole: 'viewer', deleteDialogOpen: false, deletingMember: null, @@ -80,10 +80,10 @@ export const useTeamDialogStore = create((set) => ( // 邀请成员对话框 setInviteDialogOpen: (open) => set({ inviteDialogOpen: open }), - setInviteEmail: (email) => set({ inviteEmail: email }), + setInviteUserUuid: (userUuid) => set({ inviteUserUuid: userUuid }), setInviteRole: (role) => set({ inviteRole: role }), - openInviteDialog: () => set({ inviteDialogOpen: true, inviteEmail: '', inviteRole: 'viewer' }), - closeInviteDialog: () => set({ inviteDialogOpen: false, inviteEmail: '', inviteRole: 'viewer' }), + openInviteDialog: () => set({ inviteDialogOpen: true, inviteUserUuid: '', inviteRole: 'viewer' }), + closeInviteDialog: () => set({ inviteDialogOpen: false, inviteUserUuid: '', inviteRole: 'viewer' }), // 删除成员对话框 setDeleteDialogOpen: (open) => set({ deleteDialogOpen: open }), diff --git a/plugins/services/environment/src/types.ts b/plugins/services/environment/src/types.ts index 78da3448..6d30873c 100644 --- a/plugins/services/environment/src/types.ts +++ b/plugins/services/environment/src/types.ts @@ -1,6 +1,6 @@ export interface BrowserKernelVersion { - id: number; - type_id: number; + kernel_id: string; + type_code: string; resource_name: string; version: string; name?: string; @@ -16,6 +16,7 @@ extract_root?: string; status?: string; is_latest?: boolean; + installed?: boolean; } export interface ProxyPassword { diff --git a/plugins/services/store/src/stores/auth/auth-store.ts b/plugins/services/store/src/stores/auth/auth-store.ts index 2a8f85fa..d3a73a51 100644 --- a/plugins/services/store/src/stores/auth/auth-store.ts +++ b/plugins/services/store/src/stores/auth/auth-store.ts @@ -1,115 +1,86 @@ -import { create } from 'zustand'; -import type { User } from '../../types/store.types'; -import type { AuthActions, AuthState } from './auth-store.types'; -import { - clearRememberedCredentialSafely, - ensureStateConsistency, - tryAutoLogin, -} from './auth-store.utils'; - -/** - * Auth Store - * 专门管理用户认证相关的状态 - */ -export const useAuthStore = create((set, get) => ({ - // 初始状态 - user: null, - isAuthenticated: false, - isInitializing: false, - currentWorkspaceUuid: null, - currentTeamUuid: null, - - // Actions - setUser: (user: User) => - set({ - user, - isAuthenticated: true, - currentWorkspaceUuid: user.current_workspace_uuid || null, - currentTeamUuid: user.current_team_uuid || null, - }), - - clearUser: () => - set({ - user: null, - isAuthenticated: false, - currentWorkspaceUuid: null, - currentTeamUuid: null, - }), - - setCurrentWorkspace: (workspaceUuid: string | null) => - set({ - currentWorkspaceUuid: workspaceUuid, - user: get().user - ? { - ...get().user!, - current_workspace_uuid: workspaceUuid, - } - : null, - }), - - setCurrentTeam: (teamUuid: string | null) => - set({ - currentTeamUuid: teamUuid, - user: get().user - ? { - ...get().user!, - current_team_uuid: teamUuid, - } - : null, - }), - - initAuth: async () => { - // 如果正在初始化,直接返回 - if (get().isInitializing) { - return; - } - - // 如果 Store 中已有用户信息,说明已经登录,不需要做任何事 - if (get().user) { - return; - } - - set({ isInitializing: true }); - - try { - // 动态导入 invoke,避免在非 Tauri 环境中报错 - const { invoke } = await import('@tauri-apps/api/core'); - - // 尝试使用记住的凭证自动登录 - const rememberedCredential = (await invoke('get_remembered_credential')) as - | [string, string] - | null; - - if (rememberedCredential) { - const [email, refreshToken] = rememberedCredential; - const autoLoginSuccess = await tryAutoLogin(invoke, email, refreshToken, set); - - if (autoLoginSuccess) { - return; // 自动登录成功,直接返回 - } - - // 自动登录失败(可能是 refresh_token 过期),清除保存的凭证 - console.warn('[AuthStore] 自动登录失败,清除保存的凭证'); - await clearRememberedCredentialSafely(invoke); - } - - // 检查并修复状态一致性 - await ensureStateConsistency(invoke); - - // 确保状态一致:无用户信息 = 未登录 - set({ - user: null, - isAuthenticated: false, - isInitializing: false, - }); - } catch (error) { - console.error('[AuthStore] 初始化认证状态失败:', error); - // 初始化失败,确保状态一致 - set({ - user: null, - isAuthenticated: false, - isInitializing: false, - }); - } - }, -})); +import { create } from 'zustand'; +import type { User } from '../../types/store.types'; +import type { AuthActions, AuthState } from './auth-store.types'; + +interface LocalUserResponse { + uuid: string; + nickname: string; + avatar: string; + hasPassword: boolean; + currentWorkspaceUuid?: string | null; + currentTeamUuid?: string | null; +} + +function mapLocalUser(user: LocalUserResponse): User { + return { + uuid: user.uuid, + id: user.uuid, + nickname: user.nickname, + avatar: user.avatar, + has_password: user.hasPassword, + status: 'active', + current_workspace_uuid: user.currentWorkspaceUuid ?? null, + current_team_uuid: user.currentTeamUuid ?? null, + }; +} + +export const useAuthStore = create((set, get) => ({ + user: null, + isAuthenticated: false, + isInitializing: false, + currentWorkspaceUuid: null, + currentTeamUuid: null, + + setUser: (user: User) => + set({ + user, + isAuthenticated: true, + currentWorkspaceUuid: user.current_workspace_uuid || null, + currentTeamUuid: user.current_team_uuid || null, + }), + + clearUser: () => + set({ + user: null, + isAuthenticated: false, + currentWorkspaceUuid: null, + currentTeamUuid: null, + }), + + setCurrentWorkspace: (workspaceUuid: string | null) => + set({ + currentWorkspaceUuid: workspaceUuid, + user: get().user + ? { ...get().user!, current_workspace_uuid: workspaceUuid } + : null, + }), + + setCurrentTeam: (teamUuid: string | null) => + set({ + currentTeamUuid: teamUuid, + user: get().user ? { ...get().user!, current_team_uuid: teamUuid } : null, + }), + + initAuth: async () => { + if (get().isInitializing || get().user) return; + set({ isInitializing: true }); + try { + const { invoke } = await import('@tauri-apps/api/core'); + const currentUser = await invoke('get_current_local_user'); + if (currentUser) { + const user = mapLocalUser(currentUser); + set({ + user, + isAuthenticated: true, + isInitializing: false, + currentWorkspaceUuid: user.current_workspace_uuid || null, + currentTeamUuid: user.current_team_uuid || null, + }); + return; + } + } catch (error) { + console.error('[AuthStore] 初始化本地用户会话失败:', error); + } + set({ user: null, isAuthenticated: false, isInitializing: false }); + }, +})); diff --git a/plugins/services/store/src/stores/auth/auth-store.types.ts b/plugins/services/store/src/stores/auth/auth-store.types.ts index e0e6f913..58cf355f 100644 --- a/plugins/services/store/src/stores/auth/auth-store.types.ts +++ b/plugins/services/store/src/stores/auth/auth-store.types.ts @@ -29,38 +29,5 @@ export interface AuthActions { /** 设置当前工作空间 */ setCurrentWorkspace: (workspaceUuid: string | null) => void; /** 设置当前团队 */ - setCurrentTeam: (teamUuid: string | null) => void; -} - -/** - * 登录响应数据结构 - */ -export interface LoginResponse { - access_token: string; - refresh_token: string; - user_info?: { - uuid?: string; - id?: string; - nickname?: string; - email?: string; - phone?: string; - avatar_hash?: string; - status?: string; - }; -} - -/** - * Tauri invoke 函数类型 - */ - -export type TauriInvoke = ( - cmd: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - args?: Record - // eslint-disable-next-line @typescript-eslint/no-explicit-any -) => Promise; - -/** - * Store setter 函数类型 - */ -export type AuthStateSetter = (state: Partial) => void; + setCurrentTeam: (teamUuid: string | null) => void; +} diff --git a/plugins/services/store/src/stores/auth/auth-store.utils.ts b/plugins/services/store/src/stores/auth/auth-store.utils.ts deleted file mode 100644 index 2be4fe19..00000000 --- a/plugins/services/store/src/stores/auth/auth-store.utils.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { User } from '../../types/store.types'; -import type { AuthStateSetter, LoginResponse, TauriInvoke } from './auth-store.types'; - -/** - * 将服务端用户信息转换为前端 User 类型 - */ -export function mapUserInfo(userInfo: LoginResponse['user_info'], fallbackEmail: string): User { - return { - uuid: userInfo?.uuid || '', - id: userInfo?.id || '', - nickname: userInfo?.nickname, - email: userInfo?.email || fallbackEmail, - phone: userInfo?.phone, - avatar: userInfo?.avatar_hash, - status: userInfo?.status || 'active', - }; -} - -/** - * 清除记住的凭证(静默处理错误) - */ -export async function clearRememberedCredentialSafely(invoke: TauriInvoke): Promise { - try { - await invoke('clear_remembered_credential'); - } catch (error) { - console.warn('[AuthStore] 清除记住的凭证失败:', error); - } -} - -/** - * 更新保存的 refresh_token(如果服务端返回了新的) - */ -export async function updateRememberedCredentialIfNeeded( - invoke: TauriInvoke, - email: string, - oldToken: string, - newToken: string -): Promise { - if (newToken && newToken !== oldToken) { - try { - await invoke('save_remembered_credential', { - email: email, - refreshToken: newToken, - }); - } catch (error) { - console.warn('[AuthStore] 更新记住的凭证失败:', error); - } - } -} - -/** - * 尝试使用记住的凭证自动登录 - */ -export async function tryAutoLogin( - invoke: TauriInvoke, - email: string, - refreshToken: string, - set: AuthStateSetter -): Promise { - try { - const result = (await invoke('login', { - payload: { - type: 'remember_password', - data: { - email: email, - refresh_token: refreshToken, - }, - }, - })) as { code: number; message: string; data?: LoginResponse }; - - if (result.code !== 1 || !result.data) { - return false; - } - - const loginResponse = result.data; - - // 更新保存的 refresh_token(如果服务端返回了新的) - await updateRememberedCredentialIfNeeded( - invoke, - email, - refreshToken, - loginResponse.refresh_token - ); - - // 更新 Store 中的用户状态 - if (loginResponse.user_info) { - set({ - user: mapUserInfo(loginResponse.user_info, email), - isAuthenticated: true, - isInitializing: false, - }); - return true; - } - - return false; - } catch (error) { - console.error('[AuthStore] 自动登录失败:', error); - return false; - } -} - -/** - * 检查并修复状态一致性 - */ -export async function ensureStateConsistency(invoke: TauriInvoke): Promise { - try { - const isLoggedIn = (await invoke('is_logged_in')) as boolean; - - if (isLoggedIn) { - console.warn('[AuthStore] 检测到状态不一致:Tauri 有凭证但 Store 无用户信息,清除凭证'); - await invoke('logout'); - } - } catch (error) { - console.warn('[AuthStore] 检查状态一致性失败:', error); - } -} diff --git a/plugins/services/store/src/stores/auth/index.ts b/plugins/services/store/src/stores/auth/index.ts index a87aa39e..4ce263b3 100644 --- a/plugins/services/store/src/stores/auth/index.ts +++ b/plugins/services/store/src/stores/auth/index.ts @@ -2,10 +2,7 @@ * Auth Store 统一导出 */ export { useAuthStore } from './auth-store'; -export type { - AuthActions, - AuthState, - LoginResponse, - TauriInvoke, - AuthStateSetter, -} from './auth-store.types'; +export type { + AuthActions, + AuthState, +} from './auth-store.types'; diff --git a/plugins/services/store/src/types/store.types.ts b/plugins/services/store/src/types/store.types.ts index 91e5cf1c..cada0a75 100644 --- a/plugins/services/store/src/types/store.types.ts +++ b/plugins/services/store/src/types/store.types.ts @@ -5,9 +5,8 @@ export interface User { uuid: string; id: string; nickname?: string; - email: string; - phone?: string; - avatar?: string; // 从 avatar_hash 映射 + avatar?: string; + has_password?: boolean; status: string; current_workspace_uuid?: string | null; // 当前工作空间 UUID current_team_uuid?: string | null; // 当前团队 UUID diff --git a/plugins/services/window-manager/src/index.tsx b/plugins/services/window-manager/src/index.tsx index bd706a78..89995a5a 100644 --- a/plugins/services/window-manager/src/index.tsx +++ b/plugins/services/window-manager/src/index.tsx @@ -1,161 +1,16 @@ -import { useEffect, useRef, useState } from 'react'; -import { getCurrentWindow } from '@tauri-apps/api/window'; -import { invoke } from '@/lib/tauri'; -import { listen } from '@tauri-apps/api/event'; - -/** - * 窗口管理服务插件 - * 负责在主窗口内容渲染完成后显示窗口 - */ -const WindowManagerService: React.FC = () => { - const hasShownWindow = useRef(false); - const [isLoadingComplete, setIsLoadingComplete] = useState(false); - - // 监听后端加载完成事件 - useEffect(() => { - let unsubscribeLoadingComplete: (() => void) | null = null; - - const setupListener = async () => { - try { - unsubscribeLoadingComplete = await listen('splashscreen-loading-complete', () => { - setIsLoadingComplete(true); - }); - } catch (error) { - console.error( - '[WindowManagerService] Failed to register loading complete listener:', - error - ); - } - }; - - setupListener(); - - return () => { - unsubscribeLoadingComplete?.(); - }; - }, []); - - useEffect(() => { - let animationFrameId: number; - let checkCount = 0; - const MAX_CHECKS = 500; // 最多检查 500 次(约 8 秒) - - const checkAndShowWindow = async () => { - checkCount++; - - // 如果已经显示过窗口,不再重复显示 - if (hasShownWindow.current) { - return; - } - - // 如果检查次数过多,直接显示窗口(避免无限等待) - if (checkCount > MAX_CHECKS) { - console.warn('[WindowManagerService] 检查超时,强制关闭加载窗口并显示主窗口'); - hasShownWindow.current = true; - try { - await invoke('complete_and_show_main'); - console.log('[WindowManagerService] 加载窗口已关闭,主窗口已强制显示'); - } catch (error) { - console.error('[WindowManagerService] 强制显示窗口失败:', error); - hasShownWindow.current = false; - } - return; - } - - const currentWindow = getCurrentWindow(); - - // 只处理主窗口 - if (!currentWindow.label.includes('main')) { - // 不是主窗口,停止检查 - return; - } - - // 检查主窗口的 DOM 内容是否已渲染 - const rootElement = document.getElementById('root'); - const appElement = rootElement?.querySelector('.app') as HTMLElement | null; - const isContentReady = - rootElement && - rootElement.children.length > 0 && - rootElement.offsetHeight > 0 && - rootElement.offsetWidth > 0 && - appElement && - appElement.children.length > 0 && - appElement.offsetHeight > 0; - - // 加载完成且内容已准备好,通知后端关闭加载窗口并显示主窗口 - if (isLoadingComplete && isContentReady) { - // 内容已渲染,使用双重 RAF + setTimeout 确保浏览器完成渲染后再显示窗口 - hasShownWindow.current = true; - - requestAnimationFrame(() => { - requestAnimationFrame(async () => { - // 额外的延迟确保所有样式、布局都已完成 - setTimeout(async () => { - try { - // 调用后端命令关闭加载窗口并显示主窗口 - await invoke('complete_and_show_main'); - console.log( - '[WindowManagerService] 关闭加载窗口并显示主窗口请求已发送 (检查次数:', - checkCount, - ')' - ); - } catch (error) { - console.error('[WindowManagerService] 显示窗口失败:', error); - // 如果失败,重置标志以便重试 - hasShownWindow.current = false; - } - }, 150); - }); - }); - } else { - // 每 50 次检查打印一次日志(避免日志过多) - if (checkCount % 50 === 0) { - console.log( - '[WindowManagerService] 等待主窗口就绪...', - '(检查次数:', - checkCount, - '加载完成:', - isLoadingComplete, - '内容就绪:', - isContentReady, - ')' - ); - } - // 还未准备好,继续检查(使用 RAF 避免阻塞) - animationFrameId = requestAnimationFrame(checkAndShowWindow); - } - }; - - // 延迟一点开始检查,确保 React 组件已挂载 - console.log('[WindowManagerService] 开始检查主窗口内容...'); - const timer = setTimeout(() => { - // 使用 RAF 循环直到加载完成且内容准备好 - animationFrameId = requestAnimationFrame(checkAndShowWindow); - }, 100); - - return () => { - clearTimeout(timer); - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - }; - }, [isLoadingComplete]); - - // 这个服务插件不渲染任何 UI - return null; -}; - -/** - * Window Manager 服务插件 - * 注意:这是一个特殊的服务插件,它不遵循标准的插件格式 - * 因为它需要在应用启动时就被使用,但不渲染任何 UI - */ -const windowManagerPlugin = { - id: 'window-manager', - name: 'Window Manager Service', - version: '1.0.0', - component: WindowManagerService, - slots: [], -}; - -export default windowManagerPlugin; +import { WindowManagerService } from './window-manager-service'; + +/** + * Window Manager 服务插件 + * 注意:这是一个特殊的服务插件,它不遵循标准的插件格式 + * 因为它需要在应用启动时就被使用,但不渲染任何 UI + */ +const windowManagerPlugin = { + id: 'window-manager', + name: 'Window Manager Service', + version: '1.0.0', + component: WindowManagerService, + slots: [], +}; + +export default windowManagerPlugin; diff --git a/plugins/services/window-manager/src/window-manager-service.tsx b/plugins/services/window-manager/src/window-manager-service.tsx new file mode 100644 index 00000000..0f2b7f1e --- /dev/null +++ b/plugins/services/window-manager/src/window-manager-service.tsx @@ -0,0 +1,106 @@ +import { useEffect, useRef } from 'react'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { invoke } from '@/lib/tauri'; +import { elapsedSinceMainHtml, mainStartupTiming } from '@/main-startup-timing'; + +/** 在关键应用状态和首帧布局全部就绪后,向后端报告隐藏主窗口已就绪。 */ +export const WindowManagerService: React.FC = () => { + const hasReportedReady = useRef(false); + + useEffect(() => { + let animationFrameId: number; + let checkCount = 0; + mainStartupTiming.windowManagerEffectStartedAt = performance.now(); + + const checkAndReportReady = async () => { + checkCount++; + if (hasReportedReady.current) return; + + const currentWindow = getCurrentWindow(); + if (currentWindow.label !== 'main') return; + + const rootElement = document.getElementById('root'); + const appElement = rootElement?.querySelector('.app') as HTMLElement | null; + const isContentReady = + document.readyState === 'complete' && + rootElement && + rootElement.children.length > 0 && + rootElement.offsetHeight > 0 && + rootElement.offsetWidth > 0 && + appElement && + appElement.children.length > 0 && + appElement.offsetHeight > 0; + + if (isContentReady) { + hasReportedReady.current = true; + mainStartupTiming.firstContentLayoutAt = performance.now(); + + try { + await document.fonts.ready; + mainStartupTiming.fontsReadyAt = performance.now(); + requestAnimationFrame(() => { + requestAnimationFrame(async () => { + try { + mainStartupTiming.readyInvokeAt = performance.now(); + const frontendTiming = { + navigationStartEpochMs: performance.timeOrigin, + navigationToHtmlMs: mainStartupTiming.htmlStartedAt, + htmlToModuleMs: elapsedSinceMainHtml(mainStartupTiming.moduleExecutedAt), + htmlToReactRenderMs: elapsedSinceMainHtml( + mainStartupTiming.reactRenderScheduledAt + ), + htmlToWindowManagerEffectMs: elapsedSinceMainHtml( + mainStartupTiming.windowManagerEffectStartedAt + ), + htmlToFirstContentLayoutMs: elapsedSinceMainHtml( + mainStartupTiming.firstContentLayoutAt + ), + htmlToFontsReadyMs: elapsedSinceMainHtml(mainStartupTiming.fontsReadyAt), + htmlToReadyInvokeMs: elapsedSinceMainHtml(mainStartupTiming.readyInvokeAt), + }; + + void invoke('log_info', { + module: 'simprint::frontend::main', + message: `Main window frontend timing: ${JSON.stringify(frontendTiming)}`, + }).catch((error) => { + console.warn('[WindowManagerService] failed to report startup timing:', error); + }); + + await invoke('main_window_ready'); + window.dispatchEvent(new Event('simprint:main-window-ready')); + console.log( + '[WindowManagerService] 主窗口真正就绪,已报告后端(检查次数:', + checkCount, + ')' + ); + } catch (error) { + console.error('[WindowManagerService] 报告主窗口就绪失败:', error); + hasReportedReady.current = false; + animationFrameId = requestAnimationFrame(checkAndReportReady); + } + }); + }); + } catch (error) { + console.error('[WindowManagerService] 等待字体加载失败:', error); + hasReportedReady.current = false; + animationFrameId = requestAnimationFrame(checkAndReportReady); + } + return; + } + + if (checkCount % 50 === 0) { + console.log('[WindowManagerService] 等待主窗口就绪…(检查次数:', checkCount, ')'); + } + animationFrameId = requestAnimationFrame(checkAndReportReady); + }; + + console.log('[WindowManagerService] 开始检查主窗口内容…'); + animationFrameId = requestAnimationFrame(checkAndReportReady); + + return () => { + if (animationFrameId) cancelAnimationFrame(animationFrameId); + }; + }, []); + + return null; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07a94193..4b7e6525 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: '@tauri-apps/plugin-store': specifier: ^2.4.2 version: 2.4.2 + '@tauri-apps/plugin-updater': + specifier: 2.9.0 + version: 2.9.0 '@xyflow/react': specifier: ^12.10.0 version: 12.10.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1648,6 +1651,9 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} + '@tauri-apps/api@2.9.1': resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==} @@ -1666,6 +1672,9 @@ packages: '@tauri-apps/plugin-store@2.4.2': resolution: {integrity: sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==} + '@tauri-apps/plugin-updater@2.9.0': + resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -4283,6 +4292,8 @@ snapshots: tailwindcss: 4.1.18 vite: 7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2) + '@tauri-apps/api@2.11.1': {} + '@tauri-apps/api@2.9.1': {} '@tauri-apps/plugin-clipboard-manager@2.3.2': @@ -4305,6 +4316,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.9.1 + '@tauri-apps/plugin-updater@2.9.0': + dependencies: + '@tauri-apps/api': 2.11.1 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 diff --git a/server/.cargo/config.toml b/server/.cargo/config.toml new file mode 100644 index 00000000..ac5c657a --- /dev/null +++ b/server/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-msvc] +linker = "rust-lld" \ No newline at end of file diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 00000000..a9a5c4eb --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,51 @@ +# Git +.git +.gitignore + +# Rust +target/ +**/*.rs.bk +*.pdb +Cargo.lock + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# 日志文件 +*.log + +# 数据库文件 +*.db +*.sqlite +*.sqlite3 + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# 文档 +README.md +AGENTS.md +docs/ + +# 测试和开发文件 +*.test.toml +*.dev.toml + + + + + + + + + diff --git a/server/.editorconfig b/server/.editorconfig new file mode 100644 index 00000000..c52266d3 --- /dev/null +++ b/server/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{json,yml,yaml,toml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.rs] +indent_size = 4 + + + + diff --git a/server/.github/workflows/publish-ghcr-image.yml b/server/.github/workflows/publish-ghcr-image.yml new file mode 100644 index 00000000..41de940f --- /dev/null +++ b/server/.github/workflows/publish-ghcr-image.yml @@ -0,0 +1,78 @@ +name: Publish GHCR Image + +on: + push: + tags: + - "v*" + +permissions: + contents: read + packages: write + +jobs: + publish: + name: Build and Publish Image + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set image name + run: echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/simprint-server" >> "$GITHUB_ENV" + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=ref,event=tag + type=match,pattern=v(.*),group=1 + type=raw,value=latest + type=sha,prefix=sha- + + - name: Build release binary in Debian Bullseye + run: | + docker run --rm \ + -v "$PWD:/workspace" \ + -w /workspace \ + rust:1.88-bullseye \ + bash -c ' + export PATH="/usr/local/cargo/bin:/usr/local/rustup/bin:$PATH" && + apt-get update && + apt-get install -y --no-install-recommends pkg-config libssl-dev && + cargo --version && + cargo build --release --bin simprint-server + ' + + - name: Prepare Docker build context + run: | + rm -rf .dist/docker-context + mkdir -p .dist/docker-context + cp Dockerfile .dist/docker-context/ + cp docker-compose.yml .dist/docker-context/ + cp .dockerignore .dist/docker-context/ + cp target/release/simprint-server .dist/docker-context/simprint-server + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: .dist/docker-context + file: .dist/docker-context/Dockerfile + push: true + platforms: linux/amd64 + build-args: | + BINARY_NAME=simprint-server + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 00000000..53141d7f --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,39 @@ +# Rust 编译输出 +/target/ +**/*.rs.bk +*.pdb + +# Cargo +Cargo.lock + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# 日志文件 +*.log + +# 数据库文件(如果使用本地数据库) +*.db +*.sqlite +*.sqlite3 + +# 编译后的二进制文件 +console-gateway +simprint-server +update-gateway + +# Docker 镜像文件 +simprint-server-docker-*.tar.gz + +# 配置文件:仅保留示例配置可提交 +configs/* +!configs/*.example.toml + diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 00000000..416b5eda --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,86 @@ +[package] +name = "simprint-server" +version = "0.1.0" +edition = "2024" + +[lib] +name = "simprint_server" +path = "src/lib.rs" + +[dependencies] +axum = { version = "0.8", features = ["macros", "multipart", "ws"] } +tower = "0.5.2" +tower-http = { version = "0.6", features = ["cors", "default"] } +tokio = { version = "1", features = [ + "macros", + "rt-multi-thread", + "sync", + "io-util", +] } +tokio-stream = "0.1" +tokio-tungstenite = "0.26.2" +tokio-util = { version = "0.7" } +serde = { version = "^1.0.217", features = ["derive"] } +serde_json = "^1" +base64 = "0.22.1" +mio = "1.0.1" +thiserror = "2" +anyhow = "1.0.93" +chrono = { version = "0.4", features = ["serde"] } +rand = "0.9" +rand_chacha = "0.9" +md5 = "0.8" +jwt = "0.16.0" +argon2 = "0.5.3" +aes-gcm = "0.10.3" +rsa = { version = "0.9.8", features = ["sha2"] } +sha2 = "0.10.8" +sha1 = "0.10.6" +hmac = "0.12.1" +hex = "0.4" +pkcs1 = "0.7" +pkcs8 = "0.10" +pem = "3.0" +config = "0.15" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "=0.2" +once_cell = "1.20.2" +sqlx = { version = "0.8", features = [ + "sqlite", + "mysql", + "postgres", + "runtime-tokio", + "chrono", + "json", + "uuid", + "ipnetwork", + "rust_decimal", + "bigdecimal", +] } +uuid = { version = "1.11.0", features = [ + "v4", + "fast-rng", + "macro-diagnostics", + "serde", +] } +regex = "1.5.4" +lettre = "0.11" +bytes = "1.10.0" +dashmap = "6.1.0" +rcon = { version = "0.6.0", features = ["rt-tokio", "rt-async-std"] } +tungstenite = "0.26.2" +textnonce = "1.0.0" +url = "2.5.4" +urlencoding = "2.1.3" +reqwest = { version = "0.12", features = ["json"] } +lazy_static = "1.5" +rust_decimal = { version = "1.39.0", features = [ + "serde-with-str", + "db-postgres", +] } +clap = { version = "4.0", features = ["derive"] } + +[profile.dev] +incremental = false +debug = 1 diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 00000000..f7dd63b4 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,36 @@ +# 运行时镜像 +FROM debian:bullseye-slim + +# 临时禁用 APT 的 GPG 验证以解决密钥环问题 +# 安装运行时依赖(这些包来自官方 Debian 仓库) +RUN echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure \ + && echo 'APT::Get::AllowUnauthenticated "true";' >> /etc/apt/apt.conf.d/99allow-insecure \ + && apt-get update --allow-releaseinfo-change \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + libssl1.1 \ + && rm -f /etc/apt/apt.conf.d/99allow-insecure \ + && rm -rf /var/lib/apt/lists/* + +# 创建应用用户 +RUN useradd -m -u 1000 appuser + +# 设置工作目录 +WORKDIR /app + +# 构建参数:指定要复制的二进制文件名 +ARG BINARY_NAME + +# 从本地根目录复制已编译的二进制文件到固定路径 +COPY --chown=appuser:appuser ${BINARY_NAME} /app/app + +# 设置文件权限 +RUN mkdir -p /app/assets/secret /app/configs \ + && chown -R appuser:appuser /app/assets /app/configs \ + && chmod +x /app/app + +# 切换到非 root 用户 +USER appuser + +# 统一入口,配置文件路径通过 docker-compose 的 command 参数传入 +ENTRYPOINT ["/app/app"] diff --git a/server/LICENSE b/server/LICENSE new file mode 100644 index 00000000..a666a4da --- /dev/null +++ b/server/LICENSE @@ -0,0 +1,662 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU General Public License, section +13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + + Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/server/README.md b/server/README.md new file mode 100644 index 00000000..24cb5a0c --- /dev/null +++ b/server/README.md @@ -0,0 +1,125 @@ +
+

Simprint Server

+

Self-hosted backend service for Simprint workspaces, accounts, environments, proxies, and related runtime APIs.

+

+ Language Rust 2024 + Framework Axum 0.8 + Database PostgreSQL + Cache Redis +

+

+ English | 简体中文 +

+
+ +--- + +## Introduction + +Simprint Server is the backend service used by Simprint clients and self-hosted deployments. It exposes the application API, manages authentication and persistent data, initializes encryption and storage resources, and runs embedded database migrations during startup. + +It is intended for operators who want to run Simprint inside their own infrastructure instead of depending on a shared hosted backend. The service is configured through a local TOML file and is designed to work with PostgreSQL, Redis, and S3-compatible object storage. + +## Why Simprint Server? + +Running Simprint in a self-hosted setup usually requires more than just an HTTP server: + +- You need control over API availability, credentials, and storage infrastructure. +- You need deployment artifacts that are safe to publish without leaking real environment configuration. +- You need database schema upgrades to happen predictably during release and restart. +- You need one backend entry point that can serve workspace, environment, proxy, and account-related APIs together. + +Simprint Server is built around those constraints: a single Rust service, a config-first deployment model, embedded migrations, and packaging that only ships a publish-safe example configuration. + +## Features + +- **Core application API**: Serves account, workspace, team, environment, proxy, template, preference, message, extension, and local runtime endpoints from one process. +- **Authentication and secret initialization**: Supports login-related flows, token refresh, route whitelists, and RSA secret bootstrap on first startup. +- **Embedded database migrations**: Executes `sqlx` migrations automatically before the HTTP server starts accepting traffic. +- **S3-compatible storage integration**: Configures external object storage for avatars, extension assets, and version-related files. +- **Redis-backed runtime coordination**: Uses Redis for runtime coordination and cache-oriented service flows. +- **Docker-oriented release packaging**: Generates a deployment archive with `Dockerfile`, `docker-compose.yml`, and `configs/config.toml` copied from `configs/config.example.toml`. +- **Config-first execution**: Runs locally and in containers with the same `-f ` startup model. + +## Quick Start + +### Prerequisites + +- Rust toolchain +- PostgreSQL 16+ or a compatible PostgreSQL instance +- Redis 7+ +- S3-compatible object storage +- Optional SMTP server for email-related flows + +### One-line self-hosted server install + +Linux servers can bootstrap the self-hosted backend with: + +```bash +curl -fsSL https://raw.githubusercontent.com/Simprint/simprint/main/deploy/install-server.sh | bash # Update the client config afterwards, for example: base_url = http://127.0.0.1:40041/api/ +``` + +### Run locally + +```bash +cp configs/config.example.toml configs/config.local.toml +# edit configs/config.local.toml +cargo run -- -f configs/config.local.toml +``` + +The example configuration listens on port `40041` and uses the `/api/v1` prefix by default. + +### Build a Docker release package + +Use: + +```bash +uv run python build_docker.py +``` + +The default build produces: + +- `./simprint-server` +- `./simprint-server-docker-*.tar.gz` + +You can also use options such as: + +```bash +uv run python build_docker.py --clean +uv run python build_docker.py --no-package +uv run python build_docker.py --format zip +uv run python build_docker.py --dev --no-package +``` + +The packaged `configs/config.toml` is generated from `configs/config.example.toml`, and real environment-specific config files are intentionally not included in the release archive. + +## Status + +Simprint Server was originally developed as part of a private commercial backend stack. This repository is now being prepared for a public open-source release, and the documentation is being rewritten to make standalone self-hosted deployment easier to understand. + +Some modules and naming still reflect earlier internal deployment assumptions. The current direction is to keep the client-facing gateway service deployable as an independent repository with a cleaner public-facing setup. + +## Contributing + +This repository is still in an open-source refactoring phase, but issues and pull requests are welcome. + +High-value contribution areas include: + +- Self-hosted deployment docs and onboarding improvements +- Test coverage and regression verification +- API documentation and route-level usage examples +- Packaging, release, and CI improvements + +Useful entry points when exploring the codebase: + +- `src/main.rs` +- `src/cli.rs` +- `configs/config.example.toml` +- `build_docker.py` +- `docs/` + +## License + +This project is licensed under the GNU Affero General Public License v3.0 (AGPLv3). + +If you want to use Simprint Server in a way that does not comply with the AGPLv3 obligations, including distributing modified versions or providing modified versions as a closed-source service, please contact us for a commercial license. diff --git a/server/README.zh-CN.md b/server/README.zh-CN.md new file mode 100644 index 00000000..34a14319 --- /dev/null +++ b/server/README.zh-CN.md @@ -0,0 +1,125 @@ +
+

Simprint Server

+

面向 Simprint 工作区、账号体系、环境管理、代理资源和运行时接口的自托管后端服务。

+

+ Language Rust 2024 + Framework Axum 0.8 + Database PostgreSQL + Cache Redis +

+

+ English | 简体中文 +

+
+ +--- + +## Introduction + +Simprint Server 是 Simprint 客户端和私有化部署场景使用的后端服务。它负责暴露应用 API、管理认证与持久化数据、初始化加密和存储相关资源,并在启动时自动执行内嵌的数据库迁移。 + +它面向希望把 Simprint 部署在自有基础设施中的使用者,而不是依赖共享托管后端。服务通过本地 TOML 配置文件驱动,默认围绕 PostgreSQL、Redis 和兼容 S3 的对象存储来组织运行环境。 + +## Why Simprint Server? + +想把 Simprint 作为自托管服务落地,通常不只是“起一个 HTTP 服务”这么简单: + +- 你需要控制 API 可用性、认证凭据和对象存储基础设施。 +- 你需要一套可以公开发布、但不会泄露真实环境配置的部署产物。 +- 你需要在发布和重启过程中稳定地完成数据库结构升级。 +- 你需要一个统一的后端入口来承载工作区、环境、代理和账号相关接口。 + +Simprint Server 的设计就是围绕这些约束展开的:单个 Rust 服务、配置优先的部署模型、内嵌数据库迁移,以及仅打包可公开示例配置的发布流程。 + +## Features + +- **核心应用 API**:在一个进程中承载账号、工作区、团队、环境、代理、模板、偏好、消息、扩展和本地运行时等接口。 +- **认证与密钥初始化**:支持登录相关流程、令牌刷新、白名单路由以及首次启动时的 RSA 密钥初始化。 +- **内嵌数据库迁移**:在 HTTP 服务开始接收流量前自动执行 `sqlx` migrations。 +- **兼容 S3 的对象存储集成**:为头像、扩展资源和版本相关文件接入外部对象存储。 +- **基于 Redis 的运行时协同**:使用 Redis 承担运行时协同和缓存类服务能力。 +- **面向 Docker 的发布打包**:生成包含 `Dockerfile`、`docker-compose.yml` 以及由 `configs/config.example.toml` 复制出的 `configs/config.toml` 的部署包。 +- **配置优先的运行方式**:本地运行和容器运行都使用同一套 `-f ` 启动模型。 + +## Quick Start + +### Prerequisites + +- Rust toolchain +- PostgreSQL 16+ 或兼容的 PostgreSQL 实例 +- Redis 7+ +- 兼容 S3 的对象存储 +- 可选的 SMTP 服务,用于邮件相关流程 + +### 一键安装自托管服务端 + +Linux 服务器可直接执行: + +```bash +curl -fsSL https://raw.githubusercontent.com/Simprint/simprint/main/deploy/install-server.sh | bash # 请修改客户端的配置, 如: base_url = http://127.0.0.1:40041/api/ +``` + +### 本地运行 + +```bash +cp configs/config.example.toml configs/config.local.toml +# 修改 configs/config.local.toml +cargo run -- -f configs/config.local.toml +``` + +示例配置默认监听 `40041` 端口,并使用 `/api/v1` 作为接口前缀。 + +### 构建 Docker 发布包 + +使用: + +```bash +uv run python build_docker.py +``` + +默认构建产物包括: + +- `./simprint-server` +- `./simprint-server-docker-*.tar.gz` + +也可以使用这些常见参数: + +```bash +uv run python build_docker.py --clean +uv run python build_docker.py --no-package +uv run python build_docker.py --format zip +uv run python build_docker.py --dev --no-package +``` + +打包后的 `configs/config.toml` 来自仓库中的 `configs/config.example.toml`,真实环境配置文件不会被包含在对外发布的部署包中。 + +## Status + +Simprint Server 最初是作为私有商业后端体系的一部分开发的。当前这个仓库正在为公开开源发布做整理,文档也在同步重写,以便外部使用者更容易理解和部署独立的自托管版本。 + +仓库中的部分模块划分和命名,仍然会带有早期内部部署模型的痕迹。当前方向是把面向客户端的网关服务整理成一个可以独立部署、便于公开协作的仓库。 + +## Contributing + +这个仓库目前仍处于开源重构阶段,但已经欢迎通过 Issue 和 Pull Request 参与改进。 + +当前更有价值的贡献方向包括: + +- 自托管部署文档和上手流程优化 +- 测试覆盖和回归验证补充 +- API 文档和路由级使用示例完善 +- 打包、发布和 CI 流程改进 + +如果你准备快速建立上下文,建议先看这些入口: + +- `src/main.rs` +- `src/cli.rs` +- `configs/config.example.toml` +- `build_docker.py` +- `docs/` + +## License + +本项目采用 GNU Affero General Public License v3.0 (AGPLv3) 进行许可。 + +如果你希望在不履行 AGPLv3 义务的前提下使用 Simprint Server,包括分发修改版本或以闭源服务形式提供修改版本,请联系获取商业许可。 diff --git a/server/assets/secret/private_key.pem b/server/assets/secret/private_key.pem new file mode 100644 index 00000000..f5c49742 --- /dev/null +++ b/server/assets/secret/private_key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEArd/zJ0+jxE+q7YV8nNIjfMBtu7wSYbIjUa2FnuxwbxVuDLZP +qe8zMAojrimNGkdswVdcgQl/0E5c561hgTU54a7b2PWhobiM4YBY/jUcy+ufCv4m +qp71kSCsCazAdhKP65xvuu9hkIcJyHEIDASGAYLHKjjj1zbHmZhL5uKOWLnwLbNc +0ujTPlS5McxZeF65B9IWzJa5RmRMNlYgAKYsPlIwPpy2uglXWGdX3LejMpv5qXpC +QUKjrDi1AVOD+R6HIo6J4YMXEpOKbJpiamOpWn4oZLtGTTxAqA1qO43sNpJFKMC5 +4QjO7DMlfnbs3f5oYVjBExVQuqQAsgPMHLA8zQIDAQABAoIBAAd4l/oYcfD9e1q5 +YaFbZir2GcD3U9Le1KWtzxAFGM+tHA1vx/yFSq3FwcK6BlYau5GTL2ZcAiHxPAy4 +Zngm0VXhLZEk/Mz2IRePbEOABLy+YxcV1JOAQQz7WmkQAzUPlx+ImURvBMIxCzqR +oMbjat6lv+kQiLm2oNz4rko9ceBeCsAuuEREbmKMMubFeVgDvmtHOmbZlqsfHktd +BbB2zcvj5B7PpGth0jf9/3CoM0UYoT0hXtZ9s9y0HUyO2SDuRpUsWcIjzuZLVuiV +349z6/dNlVsXRIDLku1OPuyDTbfmFyfY5jCrU7idCet9n+BmyEEVuKJ12ZBwL2PF +KyhstAECgYEA5oOKMGu5G9cx2CdN113pyJW5syM5JLwwyPIXw5Xfv9IeEBYY1di4 +5BCZvsy4ez+CoFpCgFS+aOXo7vtAG5H4F9vOLgk9wBcijACChMwGqffWM5vB/ouA +EtymQe76YA7uBw048aoVJVsOp3dGRvix68BFs5gjMFvOjQD9J3gTnP8CgYEAwRlM +xNvZqDkVSvPqIlTnQ3GU9r7SK2JnBTkIcna68f71lEa38sl5ZUwlpkaTKaZhgA77 +CHtgYRnzOlOEWGxLkd82RinyVhBUKD9LfSJpDOkPiFwwlwYXehQGC69Ib4AFyHoS +JG+KiTVv5freKPv3VhiH/LyJwrU4YGw/d8VaCjMCgYEAlvdlBGs4cyRPb5nmH/tQ +hd6RHOIfpZBefuwWZjB8tlr891oRb9Qc2riIiG35EDa67RvP284kWfzgvcrs5GGH +0tBQytOgjnJYXMpksGYSozQ+I9SJi5R/D1tUw2+oqEp+1z1woszaRnnJMiIqc4ai +t5xXydQEj8JAlxYjtbqtVa8CgYEAtRPVktGb1Y3aMtRy3kkCKZPMnmqpSffYJeSq +0DQY8TAm+Sor+6gFiAGVWMzb2fXlfqINtJGF+ujL1wlUlVrQrvVDvx4824oqcSeR +0cHAA1RWtYfGJQmYYGmAldqEsdK7GZmng7V5k1uiGGddh89ozLrqYw4mnYk1We6I +wfc2jVMCgYEAr0So34S3siiljBx9afNXnEl4DOmTWDCOMlnz7Ru+egQ02M20DHEg +iRwHqBVzTiqncVpJPXIgcitNq7DRfbQCnkLuBfd30ygATk0wYkxyQp/mb7G9Fm3x +BOdFPOMTt5yQhO7nXJH0vt2sO5cUqKy1s6pBCGU1VIxFBuFWe4TGDKs= +-----END RSA PRIVATE KEY----- diff --git a/server/assets/secret/public_key.pem b/server/assets/secret/public_key.pem new file mode 100644 index 00000000..560721f4 --- /dev/null +++ b/server/assets/secret/public_key.pem @@ -0,0 +1,8 @@ +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEArd/zJ0+jxE+q7YV8nNIjfMBtu7wSYbIjUa2FnuxwbxVuDLZPqe8z +MAojrimNGkdswVdcgQl/0E5c561hgTU54a7b2PWhobiM4YBY/jUcy+ufCv4mqp71 +kSCsCazAdhKP65xvuu9hkIcJyHEIDASGAYLHKjjj1zbHmZhL5uKOWLnwLbNc0ujT +PlS5McxZeF65B9IWzJa5RmRMNlYgAKYsPlIwPpy2uglXWGdX3LejMpv5qXpCQUKj +rDi1AVOD+R6HIo6J4YMXEpOKbJpiamOpWn4oZLtGTTxAqA1qO43sNpJFKMC54QjO +7DMlfnbs3f5oYVjBExVQuqQAsgPMHLA8zQIDAQAB +-----END RSA PUBLIC KEY----- diff --git a/server/build_docker.py b/server/build_docker.py new file mode 100644 index 00000000..11c24f10 --- /dev/null +++ b/server/build_docker.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +""" +构建脚本:编译 Rust 服务并生成包含运行容器所需文件的压缩包。 + +使用方法: + python build_docker.py [--release] [--clean] [--no-package] + +参数: + --release: 使用 release 模式编译(默认) + --clean: 构建前清理旧的二进制文件 + --no-package: 不生成压缩包,只编译和复制文件 +""" + +import os +import sys +import shutil +import subprocess +import argparse +import zipfile +import tarfile +from pathlib import Path +from datetime import datetime + +# 需要编译的服务列表(本仓库仅 simprint-server) +SERVICES = [ + "simprint-server", +] + +# 项目根目录 +PROJECT_ROOT = Path(__file__).parent.resolve() +TARGET_DIR = PROJECT_ROOT / "target" +RELEASE_DIR = TARGET_DIR / "release" + +# 需要打包到压缩包的文件和目录 +PACKAGE_FILES = [ + "Dockerfile", + "docker-compose.yml", + ".dockerignore", +] + +PACKAGE_CONFIG_FILES = [ + ("configs/config.example.toml", "configs/config.toml"), +] + +# 需要打包的二进制文件(在构建后复制) +PACKAGE_BINARIES = SERVICES + + +def print_step(message: str): + """打印步骤信息""" + print(f"\n{'='*60}") + print(f" {message}") + print(f"{'='*60}\n") + + +def check_cargo(): + """检查 cargo 是否可用""" + try: + result = subprocess.run( + ["cargo", "--version"], + capture_output=True, + text=True, + check=True, + ) + print(f"✓ 找到 Cargo: {result.stdout.strip()}") + return True + except (subprocess.CalledProcessError, FileNotFoundError): + print("✗ 错误: 未找到 Cargo,请确保已安装 Rust 工具链") + return False + + +def clean_binaries(): + """清理项目根目录中的旧二进制文件""" + print_step("清理旧的二进制文件") + removed = [] + for service in SERVICES: + binary_path = PROJECT_ROOT / service + if binary_path.exists(): + binary_path.unlink() + removed.append(service) + print(f" 删除: {binary_path}") + + if not removed: + print(" 没有需要清理的文件") + else: + print(f" 已清理 {len(removed)} 个文件") + + +def build_service(service: str, release: bool = True) -> bool: + """编译单个服务""" + build_mode = "release" if release else "dev" + print(f" 编译 {service} ({build_mode} 模式)...") + + cmd = ["cargo", "build"] + if release: + cmd.append("--release") + cmd.extend(["--bin", service]) + + try: + result = subprocess.run( + cmd, + cwd=PROJECT_ROOT, + check=True, + capture_output=True, + text=True, + ) + print(f" ✓ {service} 编译成功") + return True + except subprocess.CalledProcessError as e: + print(f" ✗ {service} 编译失败") + if e.stderr: + print(f" 错误信息: {e.stderr}") + return False + + +def copy_binary(service: str, release: bool = True, dest_dir: Path = None) -> bool: + """将编译好的二进制文件复制到指定目录""" + if dest_dir is None: + dest_dir = PROJECT_ROOT + + source_dir = RELEASE_DIR if release else TARGET_DIR / "debug" + source_path = source_dir / service + dest_path = dest_dir / service + + if not source_path.exists(): + print(f" ✗ 错误: 源文件不存在: {source_path}") + return False + + try: + shutil.copy2(source_path, dest_path) + # 设置可执行权限 + os.chmod(dest_path, 0o755) + print(f" ✓ 已复制: {service} -> {dest_path}") + return True + except Exception as e: + print(f" ✗ 复制失败: {e}") + return False + + +def create_package(package_dir: Path, output_format: str = "zip") -> Path: + """创建压缩包""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + package_name = f"simprint-server-docker-{timestamp}" + + if output_format == "zip": + archive_path = PROJECT_ROOT / f"{package_name}.zip" + print_step(f"创建 ZIP 压缩包: {archive_path.name}") + + with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zipf: + for root, dirs, files in os.walk(package_dir): + # 跳过隐藏目录 + dirs[:] = [d for d in dirs if not d.startswith(".")] + + for file in files: + file_path = Path(root) / file + arcname = file_path.relative_to(package_dir) + zipf.write(file_path, arcname) + print(f" 添加: {arcname}") + + elif output_format == "tar.gz": + archive_path = PROJECT_ROOT / f"{package_name}.tar.gz" + print_step(f"创建 TAR.GZ 压缩包: {archive_path.name}") + + with tarfile.open(archive_path, "w:gz") as tar: + for root, dirs, files in os.walk(package_dir): + # 跳过隐藏目录 + dirs[:] = [d for d in dirs if not d.startswith(".")] + + for file in files: + file_path = Path(root) / file + arcname = file_path.relative_to(package_dir) + tar.add(file_path, arcname=arcname, recursive=False) + print(f" 添加: {arcname}") + + else: + raise ValueError(f"不支持的压缩格式: {output_format}") + + size_mb = archive_path.stat().st_size / (1024 * 1024) + print(f"\n✓ 压缩包创建成功: {archive_path.name} ({size_mb:.2f} MB)") + return archive_path + + +def prepare_package_directory() -> Path: + """准备打包目录,复制所有必需的文件""" + print_step("准备打包目录") + + package_dir = PROJECT_ROOT / "docker-package" + + # 清理旧的打包目录 + if package_dir.exists(): + shutil.rmtree(package_dir) + + package_dir.mkdir(exist_ok=True) + print(f" 创建打包目录: {package_dir}") + + # 复制必需的文件和目录 + copied_count = 0 + for item in PACKAGE_FILES: + source = PROJECT_ROOT / item + dest = package_dir / item + + if not source.exists(): + print(f" ⚠ 警告: {item} 不存在,跳过") + continue + + try: + if source.is_dir(): + shutil.copytree(source, dest, dirs_exist_ok=True) + print(f" ✓ 复制目录: {item}") + else: + shutil.copy2(source, dest) + print(f" ✓ 复制文件: {item}") + copied_count += 1 + except Exception as e: + print(f" ✗ 复制失败 {item}: {e}") + + # 仅复制对外发布需要的示例配置文件 + print(f"\n 复制配置模板:") + for source_item, dest_item in PACKAGE_CONFIG_FILES: + source = PROJECT_ROOT / source_item + dest = package_dir / dest_item + + if not source.exists(): + print(f" ⚠ 警告: {source_item} 不存在,跳过") + continue + + try: + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, dest) + print(f" ✓ 复制文件: {source_item} -> {dest_item}") + copied_count += 1 + except Exception as e: + print(f" ✗ 复制失败 {source_item}: {e}") + + # 复制二进制文件 + print(f"\n 复制二进制文件:") + for binary in PACKAGE_BINARIES: + source = PROJECT_ROOT / binary + if source.exists(): + dest = package_dir / binary + shutil.copy2(source, dest) + os.chmod(dest, 0o755) + print(f" ✓ 复制: {binary}") + else: + print(f" ✗ 错误: {binary} 不存在") + + # 创建 README 文件 + readme_content = f"""# Simprint Server Docker 部署包 + +本压缩包包含运行 Simprint Server(客户端网关)容器所需的所有文件。 + +## 包含内容 + +- 二进制文件: {', '.join(SERVICES)} +- Dockerfile: Docker 镜像构建文件 +- docker-compose.yml: Docker Compose 配置文件 +- configs/config.toml: 配置模板 +- 自动生成的密钥会保存在 Docker 卷中,不随部署包分发 + +## 使用方法 + +1. 解压压缩包到目标目录 + +2. 确保 `configs/config.toml` 中的数据库、Redis、对象存储地址正确 + +3. 构建 Docker 镜像: + ```bash + docker-compose build + ``` + +4. 启动服务: + ```bash + docker-compose up -d + ``` + +5. 查看服务状态: + ```bash + docker-compose ps + ``` + +服务启动时会自动执行数据库迁移。 + +6. 查看日志: + ```bash + docker-compose logs -f simprint-server + ``` + +## 服务端口 + +- 客户端网关 (simprint-server): 40041 + +## 注意事项 + +- 请确保已安装 Docker 和 Docker Compose +- 配置文件中的外部服务地址需要根据实际环境调整 +- 首次运行前请检查 configs/ 目录下的配置文件 + +生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} +""" + + readme_path = package_dir / "README.md" + readme_path.write_text(readme_content, encoding="utf-8") + print(f" ✓ 创建: README.md") + + print(f"\n✓ 打包目录准备完成,共 {copied_count + len(PACKAGE_BINARIES)} 个项目") + return package_dir + + +def main(): + parser = argparse.ArgumentParser( + description="构建 Rust 服务二进制文件供 Docker 使用" + ) + parser.add_argument( + "--release", + action="store_true", + default=True, + help="使用 release 模式编译(默认)", + ) + parser.add_argument( + "--dev", + action="store_true", + help="使用 dev 模式编译", + ) + parser.add_argument( + "--clean", + action="store_true", + help="构建前清理旧的二进制文件", + ) + parser.add_argument( + "--no-package", + action="store_true", + help="不生成压缩包,只编译和复制文件到项目根目录", + ) + parser.add_argument( + "--format", + choices=["zip", "tar.gz"], + default="tar.gz", + help="压缩包格式 (默认: tar.gz)", + ) + args = parser.parse_args() + + # 确定编译模式 + release_mode = args.release and not args.dev + + print_step("Simprint Server Docker 构建脚本") + print(f"项目目录: {PROJECT_ROOT}") + print(f"编译模式: {'release' if release_mode else 'dev'}") + + # 检查 cargo + if not check_cargo(): + sys.exit(1) + + # 清理旧文件 + if args.clean: + clean_binaries() + + # 编译所有服务 + print_step("编译服务") + build_success = True + for service in SERVICES: + if not build_service(service, release_mode): + build_success = False + + if not build_success: + print("\n✗ 服务编译失败,请检查错误信息") + sys.exit(1) + + # 复制二进制文件 + print_step("复制二进制文件到项目根目录") + copy_success = True + for service in SERVICES: + if not copy_binary(service, release_mode): + copy_success = False + + if not copy_success: + print("\n✗ 文件复制失败") + sys.exit(1) + + # 验证文件 + print_step("验证构建结果") + all_exist = True + for service in SERVICES: + binary_path = PROJECT_ROOT / service + if binary_path.exists(): + size = binary_path.stat().st_size / (1024 * 1024) # MB + print(f" ✓ {service}: {size:.2f} MB") + else: + print(f" ✗ {service}: 文件不存在") + all_exist = False + + if not all_exist: + print("\n✗ 构建验证失败") + sys.exit(1) + + # 生成压缩包 + if not args.no_package: + package_dir = prepare_package_directory() + archive_path = create_package(package_dir, args.format) + + # 清理打包目录 + print_step("清理临时文件") + shutil.rmtree(package_dir) + print(" ✓ 已清理临时打包目录") + + print_step("构建完成!") + print(f"\n✓ 压缩包已生成: {archive_path.name}") + print(f" 位置: {archive_path}") + print("\n现在可以:") + print(" 1. 将压缩包传输到目标服务器") + print(" 2. 解压后运行: docker-compose up -d") + else: + print_step("构建完成!") + print("\n现在可以使用以下命令构建 Docker 镜像:") + print(" docker-compose build") + print("\n或者启动服务:") + print(" docker-compose up -d") + + +if __name__ == "__main__": + main() + diff --git a/server/clippy.toml b/server/clippy.toml new file mode 100644 index 00000000..5efb527b --- /dev/null +++ b/server/clippy.toml @@ -0,0 +1,7 @@ +# Clippy 配置 +avoid-breaking-exported-api = false +msrv = "1.70.0" + + + + diff --git a/server/configs/config.example.toml b/server/configs/config.example.toml new file mode 100644 index 00000000..42a21289 --- /dev/null +++ b/server/configs/config.example.toml @@ -0,0 +1,44 @@ +[app] +name = "simprint-server" +port = 40041 +secret = "change-this-to-a-long-random-secret" +prefix = "/api/v1" +encrypt_secret_location = "./assets/secret" +route_whitelists = [ + "POST+/api/v1/users/login", + "POST+/api/v1/users/register", + "POST+/api/v1/users/register-send-code", + "POST+/api/v1/users/reset-password", + "POST+/api/v1/users/reset-password-send-code", + "POST+/api/v1/users/refresh-credentials", + "GET+/api/v1/secret/public/key", + "GET+/api/v1/time/now", + "POST+/api/v1/versions/check" +] +referral_link_prefix = "https://your-domain.example/download" + +[database] +url = "postgres://simprint:change-me@postgres:5432/simprintdb" +max_connections = 25 +min_connections = 5 +max_lifetime = 3000 +acquire_timeout = 30 +idle_timeout = 600 + +[storage] +public_base_url = "https://your-resource-download-host.example" +avatar_root = "avatars" +extension_root = "extensions" +version_root = "versions" + +[smtp] +smtp_server = "smtp.example.com" +smtp_username = "noreply@example.com" +smtp_password = "change-me" + +[workspace_quota] +[workspace_quota.default] +max_environments = 99999 +max_team_members = 99999 +max_proxies = 99999 +max_rpa_tasks = 99999 diff --git a/server/configs/config.local.example.toml b/server/configs/config.local.example.toml new file mode 100644 index 00000000..1c7b789f --- /dev/null +++ b/server/configs/config.local.example.toml @@ -0,0 +1,40 @@ +[app] +name = "simprint-server-local" +port = 40041 +secret = "change-this-to-a-long-random-secret" +prefix = "/api/v1" +encrypt_secret_location = "./assets/secret" +route_whitelists = [ + "POST+/api/v1/users/login", + "POST+/api/v1/users/register", + "POST+/api/v1/users/register-send-code", + "POST+/api/v1/users/reset-password", + "POST+/api/v1/users/reset-password-send-code", + "POST+/api/v1/users/refresh-credentials", + "GET+/api/v1/secret/public/key", + "GET+/api/v1/time/now", + "POST+/api/v1/versions/check", +] +referral_link_prefix = "http://127.0.0.1:40041/download" + +# PostgreSQL remains the only external dependency during this migration stage. +[database] +url = "postgres://simprint:change-me@127.0.0.1:5432/simprintdb" +max_connections = 10 +min_connections = 1 +max_lifetime = 3000 +acquire_timeout = 30 +idle_timeout = 600 + +[storage] +public_base_url = "https://your-resource-download-host.example" +avatar_root = "avatars" +extension_root = "extensions" +version_root = "versions" + +[workspace_quota] +[workspace_quota.default] +max_environments = 99999 +max_team_members = 99999 +max_proxies = 99999 +max_rpa_tasks = 99999 diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 00000000..08b5d828 --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,68 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: simprint-postgres + environment: + POSTGRES_DB: simprintdb + POSTGRES_USER: simprint + POSTGRES_PASSWORD: change-me + volumes: + - simprint-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U simprint -d simprintdb"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + networks: + - simprint-network + + redis: + image: redis:7-alpine + container_name: simprint-redis + command: ["redis-server", "--appendonly", "yes"] + volumes: + - simprint-redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + networks: + - simprint-network + + simprint-server: + build: + context: . + dockerfile: Dockerfile + args: + BINARY_NAME: simprint-server + container_name: simprint-client-gateway + command: ["-f=/app/configs/config.toml"] + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + ports: + - "40041:40041" + volumes: + - ./configs:/app/configs:ro + - simprint-secret-data:/app/assets/secret + environment: + - RUST_LOG=info + restart: unless-stopped + networks: + - simprint-network + +networks: + simprint-network: + name: simprint-network + +volumes: + simprint-postgres-data: + simprint-redis-data: + simprint-secret-data: diff --git a/server/docs/database-design.md b/server/docs/database-design.md new file mode 100644 index 00000000..b9c349eb --- /dev/null +++ b/server/docs/database-design.md @@ -0,0 +1,109 @@ +# 数据库设计文档 + +本文档描述了 Simprint Server 项目的数据库表结构设计。 + +## 表结构定义规范 + +### users + +用户基础信息表 + +存储用户的基础标识信息,采用 UUID 作为主键,支持全局唯一标识。 + +**字段说明**: + +- `uuid` - UUID 类型,主键,全局唯一标识 +- `id` - 字符串类型,用户 ID,唯一标识,可用于业务查询(如:USER001) +- `created_at` - 时间戳类型,创建时间,NOT NULL +- `updated_at` - 时间戳类型,更新时间,NOT NULL +- `deleted_at` - 时间戳类型,删除时间(软删除),可为 NULL + +**索引**: + +- 主键索引:`uuid` +- 唯一索引:`id` +- 索引:`deleted_at`(用于软删除查询优化) + +**约束**: + +- `id` 必须唯一且不为空 +- `created_at` 和 `updated_at` 由数据库自动管理 + +### user_infos + +用户详细信息表 + +存储用户的详细业务信息,包括登录凭证、联系方式、个人信息等。通过 `user_uuid` 与 `users` 表关联。 + +**字段说明**: + +- `id` - 自增整数类型,主键 +- `user_uuid` - UUID 类型,外键,关联 `users.uuid`,NOT NULL +- `nickname` - 字符串类型,昵称,可为 NULL +- `email` - 字符串类型,邮箱地址,NOT NULL,唯一索引,用于登录 +- `phone` - 字符串类型,手机号,可为 NULL(可选字段) +- `password` - 字符串类型,密码(Argon2 加密存储),NOT NULL +- `avatar_hash` - 字符串类型,头像文件 hash(MinIO 中存储的文件名,无后缀),可为 NULL +- `status` - 字符串类型,用户状态(active/inactive/banned),默认 'active',NOT NULL +- `created_at` - 时间戳类型,创建时间,NOT NULL +- `updated_at` - 时间戳类型,更新时间,NOT NULL +- `deleted_at` - 时间戳类型,删除时间(软删除),可为 NULL + +**索引**: + +- 主键索引:`id` +- 唯一索引:`user_uuid`(一对一关系) +- 唯一索引:`email`(邮箱唯一,NOT NULL,用于登录) +- 索引:`deleted_at`(用于软删除查询优化) +- 索引:`status`(用于状态查询) + +**约束**: + +- `user_uuid` 必须唯一,确保与 `users` 表一对一关系 +- `email` 必须唯一且不为空(用于登录) +- `password` 必须不为空 +- `status` 默认值为 'active' + +**关于头像字段的说明**: + +- `avatar_hash` 字段用于存储 MinIO 中头像文件的 hash 值(文件名,无后缀) +- 目前阶段不实现头像功能,该字段可为 NULL +- 未来实现头像功能时,头像文件将存储在 MinIO 的 `avatar_bucket` 中,文件名为 hash 值 +- 获取头像 URL 时,通过配置的 `minio.resource_url` 和 `avatar_bucket` 拼接完整 URL + +**表关系**: + +- `user_infos.user_uuid` → `users.uuid`(一对一关系,级联删除) + +## 设计说明 + +### 软删除策略 + +- 所有表都支持软删除,通过 `deleted_at` 字段标记 +- 查询时默认过滤 `deleted_at IS NULL` 的记录 +- 物理删除仅在必要时进行(如数据清理) + +### 用户标识 + +- `users.uuid`:系统内部使用的全局唯一标识(UUID) +- `users.id`:业务层使用的用户 ID(字符串,如 USER001) +- `user_infos.phone`:用户登录使用的手机号 + +### 会话管理 + +- Refresh Token 存储在 Redis 中,不在数据库表中 +- 会话相关信息(设备信息、登录时间等)可在 Redis 中管理 +- 如需持久化会话记录,可考虑添加 `user_sessions` 表 + +### 验证码管理 + +- 验证码存储在 Redis 中,设置过期时间(如 5 分钟) +- 格式:`verification_code:{phone}:{type}`(type: register/reset_password) +- 不在数据库表中存储验证码信息 + +### 扩展性考虑 + +- 用户权限:如需权限系统,可添加 `user_permissions` 表 +- 用户角色:如需角色系统,可添加 `user_roles` 表和关联表 +- 登录历史:如需记录登录历史,可添加 `user_login_history` 表 +- 设备管理:如需管理用户设备,可添加 `user_devices` 表 diff --git a/server/docs/extension-system-refactor.md b/server/docs/extension-system-refactor.md new file mode 100644 index 00000000..6267ecdc --- /dev/null +++ b/server/docs/extension-system-refactor.md @@ -0,0 +1,256 @@ +# 插件系统重构方案 + +## 插件绑定的核心思路 + +### 两个关键维度 + +1. **安装范围**:决定插件作用于哪些环境 + - 全局:所有环境 + - 指定分组:特定分组的环境 + +2. **可见性**:决定插件是个人使用还是团队共享 + - 为团队安装(勾选):团队所有成员可用 + - 不勾选:仅当前用户可用 + +### 四种绑定关系 + +| 安装范围 | 为团队安装 | 绑定关系 | 效果 | +|---------|-----------|---------|------| +| 全局 | ✅ | 插件 ↔ 团队 | 团队所有成员的所有环境都有此插件 | +| 全局 | ❌ | 插件 ↔ 用户 | 仅当前用户的所有环境有此插件 | +| 指定分组 | ✅ | 插件 ↔ 分组(团队共享) | 团队所有成员在该分组下的环境都有此插件 | +| 指定分组 | ❌ | 插件 ↔ 分组(个人私有) | 仅当前用户在该分组下的环境有此插件 | + +### 关键设计原则 + +1. **环境不直接绑定插件** - 环境通过用户/团队/分组间接获得插件 +2. **动态合并** - 环境列表查询时动态合并多个层级的插件 +3. **优先级规则**(从高到低): + - 分组团队插件 + - 分组个人插件 + - 团队全局插件 + - 用户全局插件 + +### 环境获取插件的逻辑 + +当查询某个环境的插件列表时,需要合并: +1. 用户个人全局插件 +2. 团队全局插件 +3. 该环境所属分组的个人插件(如果有) +4. 该环境所属分组的团队插件(如果有) + +### 实际场景举例 + +**场景1:公司统一插件** +- 全局 + 为团队安装 ✅ +- 所有人所有环境都有 + +**场景2:个人常用插件** +- 全局 + 为团队安装 ❌ +- 只有自己所有环境有 + +**场景3:测试分组的团队插件** +- 指定分组(测试分组)+ 为团队安装 ✅ +- 所有人在测试分组的环境都有 + +**场景4:个人在某分组的特殊插件** +- 指定分组(工作分组)+ 为团队安装 ❌ +- 只有自己在工作分组的环境有 + +## 需要补充考虑的场景 + +### 1. 插件卸载场景 + +**问题:** +- 如果用户卸载了一个"团队全局插件",是只对自己生效还是影响整个团队? +- 如果用户卸载了"分组团队插件",其他团队成员还能用吗? + +**建议方案:** +- 团队插件只有所有者/管理员能卸载,卸载后影响所有人 +- 普通用户不能禁用团队插件 +- 个人插件可以直接卸载 + +### 2. 插件更新场景 + +**问题:** +- 团队插件更新到新版本,是否自动更新所有成员? +- 个人插件和团队插件版本冲突怎么办? + +**建议方案:** +- 团队插件更新后,所有成员自动使用新版本 +- 如果个人安装了同一插件的不同版本,个人版本优先 + +### 3. 权限管理场景 + +**问题:** +- 谁可以为团队安装插件? +- 谁可以为分组安装团队共享的插件? + +**建议方案:** +- 团队全局插件:只有所有者/管理员可以安装/卸载 +- 分组团队插件:拥有该分组 manage 权限的用户可以安装/卸载 +- 个人插件:所有用户都可以安装/卸载 + +### 4. 分组变更场景 + +**问题:** +- 环境从分组A移动到分组B,插件如何变化? +- 环境从有分组变为无分组,插件如何变化? + +**建议方案:** +- 环境移动分组后,插件列表自动更新(因为是动态查询的) +- 分组A的插件失效,分组B的插件生效 +- 全局插件始终生效 + +### 5. 用户离开团队场景 + +**问题:** +- 用户离开团队后,之前安装的"分组个人插件"怎么办? +- 用户的环境还能访问吗? + +**建议方案:** +- 用户离开团队,其环境也应该被删除或转移 +- 分组个人插件随环境一起处理 + +### 6. 插件冲突场景 + +**问题:** +- 同一个插件,团队安装了v1.0,个人安装了v2.0,用哪个? +- 分组团队安装了v1.0,分组个人安装了v2.0,用哪个? + +**建议方案:** +- 按优先级规则:分组团队 > 分组个人 > 团队全局 > 用户全局 +- 同一层级只能有一个版本 + +### 7. 插件商店的"已安装"标识 + +**问题:** +- 插件商店显示某个插件"已安装",但实际上可能是: + - 用户个人全局安装 + - 团队全局安装 + - 某个分组安装 + - 多个层级都安装了 + +**建议方案:** +- 显示详细的安装信息: + - "已安装(全局-团队)" + - "已安装(工作分组-个人)" + - "已安装(多处)" - 点击查看详情 + +### 8. 批量操作场景 + +**问题:** +- 能否批量为多个分组安装同一个插件? +- 能否批量卸载某个插件的所有安装? + +**建议方案:** +- 支持多选分组批量安装 +- 已安装列表支持批量卸载(但要区分权限) + +### 9. 插件依赖场景 + +**问题:** +- 插件A依赖插件B,如何处理? +- 卸载插件B时,插件A怎么办? + +**建议方案:** +- 安装时自动检查并提示安装依赖 +- 卸载时检查是否有其他插件依赖,给出警告 + +### 10. 环境启动时的插件加载 + +**问题:** +- 环境启动时,如何知道要加载哪些插件? +- 插件数据是实时查询还是缓存? + +**建议方案:** +- 环境列表接口返回每个环境的插件列表 +- 浏览器内核启动时根据这个列表加载插件 +- 插件变更后,需要重启环境才能生效 + +### 11. 插件状态管理 + +**问题:** +- 插件可能有:已安装、已启用/禁用、待更新等状态 +- 团队插件被禁用,是全局禁用还是个人禁用? + +**建议方案:** +- 团队插件的状态是全局的(所有者/管理员控制) +- 普通用户不能禁用团队插件 +- 个人插件可以自由启用/禁用 + +### 12. 审计和追溯场景 + +**问题:** +- 谁在什么时候为团队安装了什么插件? +- 插件被卸载后,如何追溯? + +**建议方案:** +- 所有插件操作记录到审计日志 +- 包括:安装者、安装时间、安装范围、是否为团队安装 + +--- + +## 实现状态 + +### 已实现功能 + +#### 1. 数据库表结构 +- ✅ `user_extensions` - 用户全局插件 +- ✅ `team_extensions` - 团队全局插件 +- ✅ `group_extensions` - 分组插件(支持 `is_team_shared` 字段区分团队/个人) +- ❌ ~~`environment_extensions`~~ - 已删除(环境不直接绑定插件) + +#### 2. 核心功能 +- ✅ 安装扩展到 user/team/group +- ✅ 卸载扩展(按目标类型) +- ✅ 查询已安装扩展(用户维度、团队维度) +- ✅ 支持批量安装到多个分组 +- ✅ `is_team_shared` 字段区分分组团队插件和分组个人插件 + +#### 3. Scope 字段语义 +返回给前端的 `scope` 字段值: +- `user` - 用户全局插件 +- `team` - 团队全局插件 +- `group-personal` - 分组个人插件(`is_team_shared=false`) +- `group-team` - 分组团队插件(`is_team_shared=true`) + +#### 4. 权限检查 +- ✅ 团队全局插件:检查用户是否为 owner/admin +- ✅ 分组团队共享插件:检查用户是否有分组的"管理"权限 +- ✅ 分组个人插件:检查用户是否有分组的"编辑"权限 + +#### 5. 审计日志 +- ✅ 安装扩展时记录审计日志 +- ✅ 卸载扩展时记录审计日志 +- ✅ 使用现有的 `audit_logs` 表和 `insert_audit_log` 函数 + +### 未实现功能 + +#### 1. 环境获取插件的动态合并逻辑 +- ❌ 环境列表接口未返回每个环境的插件列表 +- ❌ 缺少"获取某个环境的插件列表"接口 +- 需要实现:查询环境时动态合并 4 个层级的插件 + - 用户个人全局插件 + - 团队全局插件 + - 该环境所属分组的个人插件 + - 该环境所属分组的团队插件 + +#### 2. 优先级规则和版本冲突处理 +- ❌ 未实现优先级覆盖逻辑 +- 当前阶段不考虑版本问题 + +#### 3. 插件更新机制 +- ❌ 只能更新用户扩展 +- ❌ 没有团队扩展更新逻辑 +- 当前阶段不考虑更新逻辑 + +#### 4. 插件状态管理 +- ⚠️ 只有 `active/inactive` 状态 +- ❌ 没有区分"团队插件禁用"和"个人禁用" + +### 与原方案的差异 + +1. **审计日志**:使用现有的 `audit_logs` 表,而不是创建新的 `extension_audit_logs` 表 +2. **权限字段**:使用现有的"管理、编辑、查看"权限,而不是新增 `manage` 权限 +3. **环境插件加载**:暂未实现环境启动时的插件动态合并逻辑 diff --git a/server/docs/public-key-endpoint-plan.md b/server/docs/public-key-endpoint-plan.md new file mode 100644 index 00000000..9d5d39f9 --- /dev/null +++ b/server/docs/public-key-endpoint-plan.md @@ -0,0 +1,279 @@ +# 获取公钥 API 端点实现计划(已更新) + +## 概述 + +本计划用于实现 `GET /api/v1/secret/public/key` API 端点,该端点用于获取服务器的 RSA 公钥。客户端可以使用此公钥加密敏感数据,然后发送到服务器。 + +## 当前状态分析 + +### 已有功能 + +1. ✅ **路由配置**:路由已在配置文件中加入白名单(`configs/config.dev.toml` 和 `configs/config.prod.toml`) +2. ✅ **RSA 密钥工具**:`src/utils/secret/rsa.rs` 中已有 `RsaSecret` 结构和 `get_public_key()` 方法 +3. ✅ **全局 RSA 实例**:`get_rsa_secret_instance()` 函数可以获取全局 RSA 密钥实例 +4. ✅ **客户端实现参考**:`src-tauri` 中已有客户端获取公钥的实现,可作为参考 + +### 缺失功能 + +1. ❌ **Handler 函数**:缺少处理获取公钥请求的 handler 函数 +2. ❌ **路由注册**:缺少注册 `/secret/public/key` 路由的代码 +3. ❌ **Service 函数**:可选,可以直接在 handler 中调用工具函数 + +## 重要发现:客户端期望的响应格式 + +通过分析 `src-tauri/src/infrastructure/storage/credential.rs` 中的客户端实现,发现了关键信息: + +### 客户端代码分析 + +```rust +// 客户端发送 GET 请求 +let response = client.get(url).send().await?; + +// 客户端期望纯文本响应(不是 JSON) +let public_key = response.text().await?; + +// 客户端验证响应格式 +if !public_key.contains("-----BEGIN RSA PUBLIC KEY-----") { + return Err("服务器响应错误".to_string()); +} +``` + +### 关键结论 + +1. **响应格式**:客户端期望**纯文本响应**(`text/plain`),而不是 JSON 格式 +2. **响应内容**:直接返回 PEM 格式的公钥字符串,例如: + ``` + -----BEGIN RSA PUBLIC KEY----- + MIIBCgKCAQEA... + -----END RSA PUBLIC KEY----- + ``` +3. **路由路径**:`secret/public/key`(相对路径,完整路径为 `/api/v1/secret/public/key`) +4. **请求拦截器**:客户端的请求拦截器会跳过 `/secret/public/key` 路径的加密处理(见 `src-tauri/src/infrastructure/network/interceptors/request.rs`) + +## 实现方案 + +### 方案选择 + +由于获取服务器公钥是一个简单的操作,且客户端期望纯文本响应,建议: + +- **直接在 handler 中调用工具函数**,无需创建额外的 service 层 +- **返回纯文本响应**,使用 Axum 的 `PlainText` 或直接使用 `String` 实现 `IntoResponse` + +### 目录结构 + +参考现有的 `time` 模块结构: + +``` +src/ +├── handlers/ +│ ├── time.rs (已有) +│ └── secret.rs (新建) +├── routes/ +│ ├── time.rs (已有) +│ └── secret.rs (新建) +├── handlers.rs +└── routes.rs +``` + +## 实现步骤 + +### 步骤 1:创建 Handler 函数 + +**文件**:`src/handlers/secret.rs` + +**功能**: + +- 从全局 RSA 实例获取公钥 +- 返回纯文本响应(PEM 格式的公钥字符串) + +**代码结构**: + +```rust +use axum::response::{IntoResponse, Response}; +use axum::http::StatusCode; +use crate::utils::get_rsa_secret_instance; + +pub async fn get_public_key_handler() -> impl IntoResponse { + let public_key = get_rsa_secret_instance().get_public_key(); + (StatusCode::OK, public_key).into_response() +} +``` + +**说明**: + +- 使用 `impl IntoResponse` 作为返回类型,可以返回纯文本 +- `(StatusCode::OK, public_key)` 会创建一个状态码为 200 的纯文本响应 +- Axum 会自动设置 `Content-Type: text/plain; charset=utf-8` + +### 步骤 2:创建路由注册模块 + +**文件**:`src/routes/secret.rs` + +**功能**: + +- 注册 `/public/key` 路由 +- 使用 GET 方法 +- 绑定到 `get_public_key_handler` + +**代码结构**: + +```rust +use axum::routing::get; +use crate::handlers::get_public_key_handler; +use crate::routes::route::{self, MetaRoute}; + +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut secret_route = route::RouteGroup::new("/secret"); + + secret_route.add_route_item(route::RouteItem::get("/public/key", get(get_public_key_handler))); + + meta_route.add_route_group(secret_route); +} +``` + +### 步骤 3:注册模块 + +**文件**:`src/handlers.rs` + +```rust +mod time; +mod secret; // 新增 + +pub use time::*; +pub use secret::*; // 新增 +``` + +**文件**:`src/routes.rs` + +```rust +mod time; +mod secret; // 新增 + +pub fn all_routes(svc_ctx: &SvcCtx) -> Router { + // ... + time::register_routes(&mut meta_route); + secret::register_routes(&mut meta_route); // 新增 + // ... +} +``` + +### 步骤 4:验证和测试 + +1. **编译检查**:`cargo check` +2. **代码格式化**:`cargo fmt` +3. **Lint 检查**:`cargo clippy` +4. **功能测试**: + - 启动服务器 + - 使用 curl 或 Postman 测试端点: + ```bash + curl http://localhost:40041/api/v1/secret/public/key + ``` + - 验证返回的是 PEM 格式的公钥字符串 + - 验证响应头 `Content-Type` 为 `text/plain` + +## API 响应格式 + +### 成功响应(HTTP 200) + +**响应头**: + +``` +Content-Type: text/plain; charset=utf-8 +``` + +**响应体**(纯文本): + +``` +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEAseI9vA7iTxOMb5Y2xCL7BOGr1by9qEH4EfP9Bj90gxDmY8yRsVK/ +o2g+i95oQxzdvdvpPAocKlQv2FEbZaqFr2Q4vy1cLrM0B1NTZl1/hGcmPSofLT9g +mnjzP60ikY40Dxq+YXAxXZ4s2M+9thNnFr4OydacHEPEkTklcBQBglopSXc1yqHU +ARyCQ3/VxQrfh215vIPgMg2f6PH741zXFaIJjucXR8wJVySo7aZhlBTOVz5GzV0b +aWh31zA47ivXh84OXIEI+CKDUSnvsa8SCRMRs8LgaO1Xktv4yCfHHpo8Zoy+KdW0 +LJxP11G+3f9RsYRpjdmsleyHuYYS07suqwIDAQAB +-----END RSA PUBLIC KEY----- +``` + +### 错误处理 + +如果 RSA 实例未初始化,`get_rsa_secret_instance()` 会直接退出程序(在启动时已初始化,正常情况下不会发生)。 + +## 注意事项 + +1. **响应格式的重要性**: + - ⚠️ **必须返回纯文本**,不能使用 `Response` 类型(它会转换为 JSON) + - 客户端使用 `response.text().await` 期望纯文本响应 + - 客户端会验证响应包含 `-----BEGIN RSA PUBLIC KEY-----` + +2. **安全性**: + - 该端点返回的是服务器的**公钥**,是公开信息,可以安全地暴露 + - 路由已在白名单中,无需认证即可访问 + - 客户端的请求拦截器会跳过此路径的加密处理 + +3. **性能**: + - 公钥是静态数据,从内存中读取,性能开销很小 + - 无需数据库查询 + +4. **代码风格**: + - 遵循项目现有的代码风格(参考 `time.rs` 的实现) + - 但注意响应格式不同(纯文本 vs JSON) + - 添加适当的文档注释 + +5. **测试环境差异**: + - `config.test.toml` 中使用的是 `/api/v2/secret/public/key` + - 如果需要支持 v2 版本,可以在同一 handler 中注册多个路由,或创建单独的 handler + +## 客户端兼容性 + +### 客户端实现位置 + +- **文件**:`src-tauri/src/infrastructure/storage/credential.rs` +- **函数**:`fetch_server_public_key()` +- **调用时机**:应用启动时(`init_server_public_key()`) + +### 客户端验证逻辑 + +```rust +// 1. 发送 GET 请求 +let response = client.get(url).send().await?; + +// 2. 获取纯文本响应 +let public_key = response.text().await?; + +// 3. 验证公钥格式 +if !public_key.contains("-----BEGIN RSA PUBLIC KEY-----") { + return Err("服务器响应错误".to_string()); +} + +// 4. 存储公钥 +*SERVER_PUBLIC_KEY.write().unwrap() = Some(public_key.clone()); +``` + +## 后续优化建议(可选) + +1. **HTTP 缓存**:公钥是静态的,可以考虑添加 HTTP 缓存头(如 `Cache-Control: public, max-age=3600`) +2. **版本控制**:如果需要支持 v2 API,可以创建 `routes/secret_v2.rs` +3. **文档**:添加 OpenAPI/Swagger 文档(如果项目使用) + +## 相关文件清单 + +### 需要创建的文件 + +- `src/handlers/secret.rs` +- `src/routes/secret.rs` + +### 需要修改的文件 + +- `src/handlers.rs` - 添加 `mod secret;` 和 `pub use secret::*;` +- `src/routes.rs` - 添加 `mod secret;` 和调用 `secret::register_routes()` + +### 相关配置文件(无需修改,已配置) + +- `configs/config.dev.toml` - 已添加路由白名单 +- `configs/config.prod.toml` - 已添加路由白名单 +- `configs/config.test.toml` - 已添加路由白名单(v2 版本) + +### 客户端参考代码 + +- `src-tauri/src/infrastructure/storage/credential.rs` - 客户端获取公钥的实现 +- `src-tauri/src/infrastructure/network/interceptors/request.rs` - 请求拦截器(跳过公钥端点加密) diff --git a/server/docs/workspace-architecture-refactoring-report.md b/server/docs/workspace-architecture-refactoring-report.md new file mode 100644 index 00000000..d1c1cd2d --- /dev/null +++ b/server/docs/workspace-architecture-refactoring-report.md @@ -0,0 +1,686 @@ +# 工作空间架构重构变更报告 + +**重构日期**:2025-01-25 +**重构范围**:工作空间架构全面重构 +**文档版本**:v1.0 + +--- + +## 概述 + +本次重构将系统架构从"用户-团队-环境"模式升级为"用户-工作空间-团队-分组-环境"的多层级结构,实现了更灵活的资源管理、权限控制和计费体系。 + +--- + +## 1. 架构设计变更 + +### 1.1 新增架构文档 + +- **文件**:`simprint-server/docs/workspace-architecture.md` +- **内容**:完整的工作空间架构设计文档,包括: + - 核心概念和层级结构 + - 实体关系图(ASCII 格式) + - 实体详细设计 + - 权限控制体系 + - 配额管理 + - 代理可见性管理 + - 数据模型设计要点 + +### 1.2 核心设计原则 + +1. **工作空间是资源隔离边界**:所有资源(环境、代理、配额)都归属于工作空间 +2. **团队是协作组织单元**:团队成员共享团队资源,通过分组进行细粒度管理 +3. **分组是环境分类容器**:分组用于对团队内的环境进行分类和权限控制(可选) +4. **环境是核心操作单元**:所有业务操作围绕环境展开,环境必须归属团队 + +--- + +## 2. 数据库变更 + +### 2.1 新增表 + +#### 2.1.1 工作空间表(workspaces) + +- **迁移文件**:`20250125000001_create_workspaces.sql` +- **字段**: + - `uuid`:工作空间唯一标识 + - `name`:工作空间名称 + - `owner_uuid`:所有者用户 UUID + - `workspace_type`:工作空间类型(personal/team/enterprise) + - `created_at`、`updated_at`、`deleted_at`:时间戳 + +#### 2.1.2 工作空间配额表(workspace_quotas) + +- **迁移文件**:`20250125000002_create_workspace_quotas.sql` +- **字段**: + - `workspace_uuid`:工作空间 UUID(主键) + - `max_environments`、`used_environments`:环境配额 + - `max_team_members`、`used_team_members`:成员配额 + - `max_proxies`、`used_proxies`:代理配额 + - `max_rpa_tasks`、`used_rpa_tasks`:RPA 任务配额 + +#### 2.1.3 代理可见团队关联表(proxy_visible_teams) + +- **迁移文件**:`20250125000003_create_proxy_visible_teams.sql` +- **字段**: + - `proxy_uuid`:代理 UUID + - `workspace_uuid`:工作空间 UUID(冗余,便于查询) + - `team_uuid`:团队 UUID + - `created_at`:创建时间 +- **唯一约束**:`UNIQUE (proxy_uuid, team_uuid)` + +#### 2.1.4 分组权限表(group_member_permissions) + +- **迁移文件**:`20250125000004_create_group_member_permissions.sql` +- **字段**: + - `group_uuid`:分组 UUID + - `workspace_uuid`:工作空间 UUID(冗余,便于查询) + - `team_uuid`:团队 UUID(冗余,便于查询) + - `user_uuid`:用户 UUID + - `permission_type`:权限类型(read/write/manage) + - `granted_by`:授权者 UUID + - `created_at`、`updated_at`:时间戳 +- **唯一约束**:`UNIQUE (group_uuid, user_uuid)` + +### 2.2 修改现有表 + +#### 2.2.1 团队表(teams) + +- **迁移文件**:`20250125000005_alter_teams_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:所属工作空间 UUID +- **移除字段**: + - `max_members`、`max_environments`、`max_proxies`:配额移至 `workspace_quotas` + - `default_proxy_uuid`:不再需要默认代理 + +#### 2.2.2 团队成员表(team_members) + +- **迁移文件**:`20250125000006_alter_team_members_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:工作空间 UUID(冗余,便于查询) +- **移除字段**: + - `environment_count`、`group_count`:统计字段,可通过查询计算 +- **更新唯一约束**:`UNIQUE (team_uuid, user_uuid, workspace_uuid)` + +#### 2.2.3 分组表(groups) + +- **迁移文件**:`20250125000007_alter_groups_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:所属工作空间 UUID(冗余,便于查询) +- **移除字段**: + - `user_uuid`:分组属于团队,不属于用户 + - `default_proxy_uuid`:不再需要默认代理 + - `color`:前端可自行管理 +- **约束**:确保 `team_uuid` 为 NOT NULL + +#### 2.2.4 环境表(environments) + +- **迁移文件**:`20250125000008_alter_environments_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:所属工作空间 UUID(冗余,便于查询) +- **约束**:确保 `team_uuid` 为 NOT NULL + +#### 2.2.5 代理表(proxies) + +- **迁移文件**:`20250125000009_alter_proxies_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:所属工作空间 UUID(必须) + - `owner_uuid`:代理所有者 UUID(重命名自 `user_uuid`) +- **移除字段**: + - `team_uuid`:代理属于工作空间,不属于团队 + - `usage_count`:可通过查询计算 + +#### 2.2.6 订阅表(subscriptions) + +- **迁移文件**:`20250125000010_alter_subscriptions_add_workspace.sql` +- **新增字段**: + - `workspace_uuid`:所属工作空间 UUID + +#### 2.2.7 用户信息表(user_infos) + +- **迁移文件**:`20250125000013_alter_user_infos_add_current_workspace.sql` +- **新增字段**: + - `current_workspace_uuid`:用户当前工作空间 UUID + +### 2.3 数据迁移 + +#### 2.3.1 废弃用户配额表 + +- **迁移文件**:`20250125000011_deprecate_user_quotas.sql` +- **操作**:将 `user_quotas` 表重命名为 `deprecated_user_quotas` + +#### 2.3.2 数据迁移脚本 + +- **迁移文件**:`20250125000012_migrate_to_workspaces.sql` +- **操作内容**: + 1. 为每个现有用户创建默认个人工作空间 + 2. 为每个工作空间创建默认配额 + 3. 将现有 `user_quotas` 数据迁移到 `workspace_quotas` + 4. 更新所有相关表的 `workspace_uuid` 字段 + 5. 添加外键约束 + 6. 设置 `workspace_uuid` 为 NOT NULL + +--- + +## 3. 代码层变更 + +### 3.1 DTO 层(Data Transfer Object) + +#### 3.1.1 新增 DTO + +- `simprint-server/src/dto/workspaces.rs`:工作空间 DTO +- `simprint-server/src/dto/workspace_quotas.rs`:工作空间配额 DTO +- `simprint-server/src/dto/proxy_visible_teams.rs`:代理可见团队 DTO +- `simprint-server/src/dto/group_member_permissions.rs`:分组权限 DTO + +#### 3.1.2 修改现有 DTO + +- `simprint-server/src/dto/teams.rs`:添加 `workspace_uuid`,移除配额相关字段 +- `simprint-server/src/dto/groups.rs`:添加 `workspace_uuid`,移除 `user_uuid`、`default_proxy_uuid`、`color` +- `simprint-server/src/dto/environments.rs`:添加 `workspace_uuid` +- `simprint-server/src/dto/proxies.rs`:添加 `workspace_uuid`、`owner_uuid`,移除 `team_uuid` +- `simprint-server/src/dto/subscriptions.rs`:添加 `workspace_uuid` +- `simprint-server/src/dto/user.rs`:添加 `current_workspace_uuid` + +### 3.2 Entity 层(请求/响应实体) + +#### 3.2.1 新增 Entity + +- `simprint-server/src/entitys/workspaces.rs`:工作空间相关请求/响应 +- `simprint-server/src/entitys/workspace_quotas.rs`:工作空间配额响应 +- `simprint-server/src/entitys/proxy_visible_teams.rs`:代理可见性请求/响应 +- `simprint-server/src/entitys/group_member_permissions.rs`:分组权限请求/响应 + +#### 3.2.2 修改现有 Entity + +- `simprint-server/src/entitys/teams.rs`:添加 `workspace_uuid`,移除 `default_proxy_uuid` +- `simprint-server/src/entitys/groups.rs`:添加 `workspace_uuid`、`team_uuid`,移除 `default_proxy_uuid` +- `simprint-server/src/entitys/environments.rs`:添加 `workspace_uuid`、`team_uuid` +- `simprint-server/src/entitys/proxies.rs`:添加 `workspace_uuid` +- `simprint-server/src/entitys/subscriptions.rs`:添加 `workspace_uuid` + +### 3.3 Model 层(数据库操作) + +#### 3.3.1 新增 Model + +- `simprint-server/src/models/workspaces.rs`:工作空间数据库操作 +- `simprint-server/src/models/workspace_quotas.rs`:工作空间配额数据库操作 +- `simprint-server/src/models/proxy_visible_teams.rs`:代理可见性数据库操作 +- `simprint-server/src/models/group_member_permissions.rs`:分组权限数据库操作 + +#### 3.3.2 修改现有 Model + +- `simprint-server/src/models/teams.rs`: + - `fetch_team_member`:添加 `workspace_uuid` 参数(工作空间级别隔离) + - `insert_team`:添加 `workspace_uuid` 参数 + - `fetch_team_by_uuid`:添加 `workspace_uuid` 到 SELECT + - `fetch_user_teams`:添加 `workspace_uuid` 过滤 + - 移除配额相关逻辑 + +- `simprint-server/src/models/team_members.rs`: + - 所有函数添加 `workspace_uuid` 参数 + - SQL 查询包含 `workspace_uuid` 过滤 + +- `simprint-server/src/models/groups.rs`: + - `insert_group`:添加 `workspace_uuid`、`team_uuid` 参数,移除 `user_uuid` + - `fetch_groups`:使用 `workspace_uuid` 和 `team_uuid` 过滤 + - 移除 `default_proxy_uuid` 相关逻辑 + +- `simprint-server/src/models/environments.rs`: + - `insert_environment`:添加 `workspace_uuid` 参数 + - `fetch_environment_by_uuid`:添加 `workspace_uuid` 参数进行过滤 + - 新增 `fetch_environment_by_uuid_unfiltered`:用于内部查询 + - 所有查询函数添加 `workspace_uuid` 过滤 + +- `simprint-server/src/models/proxies.rs`: + - `insert_proxy`:添加 `workspace_uuid` 参数,`user_uuid` 改为 `owner_uuid`,移除 `team_uuid` + - `fetch_proxies`:使用 `workspace_uuid` 和 `owner_uuid` 过滤 + - 移除 `usage_count` 相关逻辑 + +- `simprint-server/src/models/subscriptions.rs`: + - 所有函数添加 `workspace_uuid` 参数 + +- `simprint-server/src/models/user.rs`: + - `create_user_with_info`:创建默认工作空间和配额 + - `fetch_user_info_by_uuid`、`fetch_user_info_by_email`:添加 `current_workspace_uuid` 到 SELECT + +### 3.4 Service 层(业务逻辑) + +#### 3.4.1 新增 Service + +- `simprint-server/src/services/workspaces.rs`:工作空间业务逻辑 +- `simprint-server/src/services/workspace_quotas.rs`:工作空间配额业务逻辑 +- `simprint-server/src/services/proxy_visibility.rs`:代理可见性业务逻辑 +- `simprint-server/src/services/group_permissions.rs`:分组权限业务逻辑 + +#### 3.4.2 修改现有 Service + +**环境服务(environments.rs)**: + +- `create_environment_service`: + - 添加工作空间级别团队成员检查 + - 添加分组权限检查(write/manage) + - 添加团队角色权限检查(Editor/Admin/Owner) + - 添加配额检查和更新 +- `get_environment_service`: + - 添加工作空间过滤 + - 添加分组 read 权限检查 +- `get_environments_service`: + - 添加批量权限过滤(根据分组权限) +- `update_environment_service`: + - 添加编辑权限检查(分组 write/manage 或团队角色) +- `delete_environment_service`: + - 添加删除权限检查(分组 manage 或团队 Owner/Admin) + - 添加配额更新 + +**分组服务(groups.rs)**: + +- `create_group_service`:添加 Owner/Admin 权限检查 +- `update_group_service`:添加 Owner/Admin 或 manage 权限检查 +- `delete_group_service`:添加 Owner/Admin 或 manage 权限检查 + +**团队服务(teams.rs)**: + +- 所有函数添加 `workspace_uuid` 参数 +- `switch_team_service`:更新 `current_workspace_uuid` + +**代理服务(proxies.rs)**: + +- `create_proxy_service`:添加配额检查和更新 +- `delete_proxy_service`:添加配额更新 +- `batch_import_proxies_service`:添加配额检查和更新 + +**模板服务(templates.rs)**: + +- `create_template_service`:从源环境获取 `workspace_uuid` 和 `team_uuid` 进行权限检查 +- `apply_template_service`:添加权限检查参数 + +### 3.5 Handler 层(API 端点) + +#### 3.5.1 新增 Handler + +- `simprint-server/src/handlers/client/workspaces.rs`:工作空间 API 端点 +- `simprint-server/src/handlers/client/workspace_quotas.rs`:工作空间配额 API 端点 +- `simprint-server/src/handlers/client/proxy_visibility.rs`:代理可见性 API 端点 +- `simprint-server/src/handlers/client/group_permissions.rs`:分组权限 API 端点 + +#### 3.5.2 修改现有 Handler + +- `simprint-server/src/handlers/client/teams.rs`:使用 `workspace_uuid` 从 `RequestContext` 获取 +- `simprint-server/src/handlers/client/environments.rs`:使用 `workspace_uuid` 和 `team_uuid` 从 `RequestContext` 获取 +- `simprint-server/src/handlers/client/proxies.rs`:使用 `workspace_uuid` 和 `owner_uuid` 从 `RequestContext` 获取 +- `simprint-server/src/handlers/client/templates.rs`:添加权限检查参数 + +### 3.6 Route 层(路由注册) + +#### 3.6.1 新增 Route + +- `simprint-server/src/routes/client/workspaces.rs`:工作空间路由 +- `simprint-server/src/routes/client/workspace_quotas.rs`:工作空间配额路由 +- `simprint-server/src/routes/client/proxy_visibility.rs`:代理可见性路由 +- `simprint-server/src/routes/client/group_permissions.rs`:分组权限路由 + +#### 3.6.2 修改现有 Route + +- `simprint-server/src/routes/client/mod.rs`:注册新路由模块 +- `simprint-server/src/main.rs`:注册所有新路由 + +### 3.7 中间件和状态管理 + +#### 3.7.1 状态管理(state.rs) + +- 添加 `CurrentWorkspace` 结构体 +- `RequestContext` 添加 `current_workspace_uuid` 字段 + +#### 3.7.2 认证中间件(middlewares/auth.rs) + +- 从 `user_infos` 获取 `current_workspace_uuid` 并设置到 `RequestContext` + +--- + +## 4. 核心功能实现 + +### 4.1 权限检查体系 + +#### 4.1.1 工作空间级别隔离 + +- 所有团队成员关系查询都包含 `workspace_uuid` 过滤 +- `fetch_team_member` 函数签名包含 `workspace_uuid` 参数 +- 确保用户在不同工作空间中有不同的团队成员身份 + +#### 4.1.2 环境权限检查 + +- **创建环境**: + - 检查用户是否在当前工作空间的团队中 + - 如果指定分组,检查分组 write/manage 权限 + - 如果未指定分组,检查团队角色权限(Editor/Admin/Owner) + - 检查工作空间配额 +- **查看环境**: + - 检查用户是否在当前工作空间的团队中 + - 如果环境有分组,检查分组 read 权限 + - Owner/Admin 自动拥有所有分组权限 + - 无分组环境,所有团队成员都可以查看 +- **更新环境**: + - 检查分组 write/manage 权限或团队角色权限(Editor/Admin/Owner) +- **删除环境**: + - 检查分组 manage 权限或团队 Owner/Admin 角色 + +#### 4.1.3 分组权限检查 + +- **创建分组**:只有 Owner/Admin 可以创建 +- **更新分组**:Owner/Admin 或拥有 manage 权限 +- **删除分组**:Owner/Admin 或拥有 manage 权限 +- **分组权限函数**:`check_group_permission` 自动处理 Owner/Admin 权限 + +### 4.2 配额管理体系 + +#### 4.2.1 环境配额 + +- **检查**:`check_quota(workspace_uuid, "environments")` +- **更新**: + - 创建环境:`increment_used_environments(workspace_uuid, 1)` + - 删除环境:`decrement_used_environments(workspace_uuid, 1)` + +#### 4.2.2 代理配额 + +- **检查**:`check_quota(workspace_uuid, "proxies")` +- **更新**: + - 创建代理:`increment_used_proxies(workspace_uuid, 1)` + - 删除代理:`decrement_used_proxies(workspace_uuid, 1)` + - 批量导入:每成功导入一个代理就更新一次 + +#### 4.2.3 成员配额 + +- **检查**:`check_quota(workspace_uuid, "team_members")` +- **更新**: + - 接受邀请:`update_used_team_members(workspace_uuid)`(仅当用户之前不是该工作空间的成员时) + - 移除成员:`update_used_team_members(workspace_uuid)` + - 退出团队:`update_used_team_members(workspace_uuid)` +- **注意**:成员配额是统计所有团队的活跃成员总数,需要重新计算 + +### 4.3 代理可见性管理 + +#### 4.3.1 可见性规则 + +1. **工作空间 Owner**:可以看到所有代理 +2. **代理所有者**:可以看到自己的代理(无论是否在可见列表中) +3. **团队成员**:只能看到 `proxy_visible_teams` 中包含其团队的代理 +4. **否则**:不可见 + +#### 4.3.2 可见性设置 + +- 只有代理所有者或工作空间所有者可以设置可见性 +- 通过 `proxy_visible_teams` 关联表控制可见性 +- 支持批量设置可见性 + +### 4.4 工作空间管理 + +#### 4.4.1 工作空间创建 + +- 用户注册时自动创建个人工作空间 +- 支持创建不同类型的工作空间(personal/team/enterprise) +- 自动创建默认配额 + +#### 4.4.2 工作空间切换 + +- 用户可以在多个工作空间之间切换 +- `user_infos.current_workspace_uuid` 记录当前工作空间 +- 认证中间件自动设置当前工作空间 + +--- + +## 5. API 端点变更 + +### 5.1 新增 API 端点 + +#### 5.1.1 工作空间相关 + +- `POST /workspaces/create`:创建工作空间 +- `POST /workspaces/list`:获取用户的工作空间列表 +- `POST /workspaces/get`:获取工作空间详情 +- `POST /workspaces/update`:更新工作空间 +- `POST /workspaces/delete`:删除工作空间 +- `POST /workspaces/switch`:切换工作空间 + +#### 5.1.2 工作空间配额相关 + +- `POST /workspace-quotas/get`:获取工作空间配额 +- `POST /workspace-quotas/update`:更新配额使用情况 + +#### 5.1.3 代理可见性相关 + +- `POST /proxy-visibility/set`:设置代理对团队可见 +- `POST /proxy-visibility/remove`:移除代理对团队的可见性 +- `POST /proxy-visibility/batch-set`:批量设置代理可见性 +- `POST /proxy-visibility/list-visible`:获取可见的代理列表 +- `POST /proxy-visibility/list-teams`:获取代理的可见团队列表 + +#### 5.1.4 分组权限相关 + +- `POST /group-permissions/grant`:授予分组权限 +- `POST /group-permissions/revoke`:撤销分组权限 +- `POST /group-permissions/check`:检查分组权限 +- `POST /group-permissions/list`:列出用户的分组权限 + +### 5.2 修改现有 API 端点 + +所有现有 API 端点现在都需要: + +- 从 `RequestContext` 获取 `current_workspace_uuid` +- 从 `RequestContext` 获取 `current_team_uuid`(如适用) +- 传递 `workspace_uuid` 和 `team_uuid` 到服务层 + +--- + +## 6. 数据模型设计要点 + +### 6.1 冗余字段设计 + +为了优化查询性能,以下表包含冗余字段: + +- `groups.workspace_uuid`:避免 JOIN 查询 +- `environments.workspace_uuid`:便于直接查询工作空间的环境 +- `team_members.workspace_uuid`:便于直接查询工作空间内的团队成员关系(工作空间级别的隔离) +- `group_member_permissions.workspace_uuid`、`team_uuid`:便于权限查询 +- `proxy_visible_teams.workspace_uuid`:便于直接查询工作空间内的代理可见性 + +### 6.2 软删除策略 + +所有表都支持软删除: + +- 使用 `deleted_at` 字段标记删除 +- 查询时默认过滤已删除记录 +- 支持数据恢复 + +### 6.3 时间戳管理 + +所有表都包含时间戳字段: + +- `created_at`:创建时间 +- `updated_at`:更新时间(自动更新) +- `deleted_at`:软删除时间(如适用) + +### 6.4 UUID 主键 + +所有表使用 UUID 作为主键: + +- 全局唯一标识 +- 避免 ID 冲突 +- 支持分布式系统 + +--- + +## 7. 关键设计决策 + +### 7.1 工作空间级别隔离 + +**决策**:团队成员关系是工作空间级别的,不是全局的。 + +**原因**: + +- 用户在不同工作空间中可能有不同的团队成员身份和角色 +- 提供更好的资源隔离和权限控制 +- 支持多工作空间场景 + +**实现**: + +- `team_members` 表包含 `workspace_uuid` 字段(冗余) +- 所有团队成员查询都包含 `workspace_uuid` 过滤 +- 唯一约束包含 `workspace_uuid`:`UNIQUE (team_uuid, user_uuid, workspace_uuid)` + +### 7.2 环境直接归属团队 + +**决策**:环境必须直接归属团队,分组是可选的分类容器。 + +**原因**: + +- 简化数据模型 +- 提供更清晰的权限控制 +- 支持无分组场景 + +**实现**: + +- `environments.team_uuid` 为 NOT NULL +- `environments.group_uuid` 为可选(NULL 表示直接归属团队) +- 权限控制:有分组用分组权限,无分组用团队权限 + +### 7.3 代理属于工作空间 + +**决策**:代理属于工作空间,不属于团队,通过可见性控制访问。 + +**原因**: + +- 代理是工作空间级别的资源 +- 支持跨团队共享代理 +- 提供更灵活的代理管理 + +**实现**: + +- `proxies.workspace_uuid` 为必须 +- `proxies.owner_uuid` 记录代理所有者 +- `proxy_visible_teams` 关联表控制可见性 + +### 7.4 配额基于工作空间 + +**决策**:配额是工作空间级别的,所有团队共享。 + +**原因**: + +- 简化配额管理 +- 支持统一计费 +- 便于资源分配 + +**实现**: + +- `workspace_quotas` 表存储工作空间配额 +- 所有配额检查和更新都基于 `workspace_uuid` +- 成员配额统计所有团队的活跃成员总数 + +--- + +## 8. 迁移和兼容性 + +### 8.1 数据迁移 + +- 为每个现有用户创建默认个人工作空间 +- 将现有 `user_quotas` 数据迁移到 `workspace_quotas` +- 更新所有相关表的 `workspace_uuid` 字段 +- 确保数据完整性 + +### 8.2 向后兼容 + +- 保留 `deprecated_user_quotas` 表用于历史数据 +- 迁移脚本确保所有现有数据都有对应的 `workspace_uuid` +- 用户注册时自动创建个人工作空间 + +--- + +## 9. 测试建议 + +### 9.1 单元测试 + +- 工作空间级别隔离测试 +- 权限检查函数测试 +- 配额管理函数测试 + +### 9.2 集成测试 + +- 代理可见性逻辑测试 +- 分组权限控制测试 +- 工作空间切换测试 + +### 9.3 安全测试 + +- 跨工作空间访问测试 +- 权限绕过测试 +- 配额绕过测试 + +--- + +## 10. 已知问题和限制 + +### 10.1 数据库约束 + +- 迁移脚本中注释掉了 `team_uuid` 的 NOT NULL 约束 +- **建议**:在数据迁移完成后,确保所有环境都有 `team_uuid`,然后添加 NOT NULL 约束 + +### 10.2 性能优化 + +- 冗余字段设计已优化查询性能 +- 建议添加适当的索引以进一步提升性能 + +--- + +## 11. 后续工作 + +### 11.1 待完成 + +- [ ] 在数据迁移完成后,添加 `team_uuid` 的 NOT NULL 约束 +- [ ] 添加单元测试和集成测试 +- [ ] 进行安全审查 + +### 11.2 优化建议 + +- [ ] 添加缓存层以提升性能 +- [ ] 优化配额统计查询 +- [ ] 添加配额使用情况监控 + +--- + +## 12. 总结 + +本次重构实现了完整的工作空间架构,包括: + +✅ **核心功能**: + +- 工作空间管理 +- 工作空间级别隔离 +- 权限检查体系 +- 配额管理体系 +- 代理可见性管理 + +✅ **数据模型**: + +- 4 个新表 +- 7 个表的结构修改 +- 完整的数据迁移脚本 + +✅ **代码实现**: + +- 4 个新的 Service 模块 +- 4 个新的 Handler 模块 +- 4 个新的 Route 模块 +- 所有现有模块的更新 + +✅ **API 端点**: + +- 15+ 个新的 API 端点 +- 所有现有端点的更新 + +所有实现都符合 `workspace-architecture.md` 文档的设计要求,确保了系统的可扩展性、安全性和可维护性。 + +--- + +**报告生成时间**:2025-01-25 +**报告版本**:v1.0 +**文档维护者**:开发团队 diff --git a/server/docs/workspace-architecture.md b/server/docs/workspace-architecture.md new file mode 100644 index 00000000..3403ff90 --- /dev/null +++ b/server/docs/workspace-architecture.md @@ -0,0 +1,884 @@ +# 工作空间架构设计 + +## 概述 + +本文档描述了 Simprint 系统的核心架构设计,采用"用户-工作空间-团队-分组-环境"的多层级结构,实现灵活的资源管理、权限控制和计费体系。 + +## 核心概念 + +### 层级结构 + +``` +用户(User) + └─ 工作空间(Workspace) + ├─ 团队(Team) + │ ├─ 成员(Team Members) + │ └─ 分组(Group) + │ └─ 环境(Environment) + ├─ 代理(Proxy) + ├─ 配额(Quota) + └─ 订阅(Subscription) +``` + +### 关键设计原则 + +1. **工作空间是资源隔离边界**:所有资源(环境、代理、配额)都归属于工作空间 +2. **团队是协作组织单元**:团队成员共享团队资源,通过分组进行细粒度管理 +3. **分组是环境分类容器**:分组用于对团队内的环境进行分类和权限控制 +4. **环境是核心操作单元**:所有业务操作围绕环境展开 + +## 实体关系图 + +### 核心实体关系 + +``` + ┌───────────────────────┐ + │ users │ + │ (用户基础信息) │ + └───────────┬───────────┘ + │ + │ 1:N + │ + ┌───────────────────────────┼───────────────────────────┐ + │ │ │ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │ workspaces │ │ user_infos │ │ user_wallets │ + │ (工作空间) │ │ (用户详情) │ │ (用户钱包) │ + └───────┬───────┘ └───────────────┘ └───────────────┘ + │ + │ 1:N (owner) + │ + ▼ + ┌───────────────┐ + │ teams │◄──────────┐ + │ (团队) │ │ + └───────┬───────┘ │ + │ │ N:M + │ │ + ┌───────────┼───────────┐ │ + │ │ │ │ + │ 1:N │ 1:N │ │ + │ (可选) │ (必须) │ │ + │ │ │ │ + ▼ ▼ │ │ +┌───────────────┐ ┌───────────────┐ │ +│ groups │ │ environments │ │ +│ (分组) │ │ (环境) │ │ +│ (可选分类) │ │ (直接归属团队) │ │ +└───────┬───────┘ └───────┬───────┘ │ + │ │ │ + │ N:1 (可选) │ N:1 (必须)│ + │ (环境可选归属分组)│ (环境必须归属团队)│ + │ │ │ + └─────────┬───────┘ │ + │ │ + │ │ + │ + │ + ┌───────────────┴───────────────┐ + │ │ + ▼ ▼ + ┌───────────────┐ ┌───────────────┐ + │team_members │──────────────►│ users │ + │(团队成员关系) │ │ (用户) │ + │(工作空间级别) │ │ │ + └───────┬───────┘ └───────────────┘ + │ + │ N:1 (冗余) + │ (通过 team.workspace_uuid) + │ + ▼ + ┌───────────────┐ + │ workspaces │ + │ (工作空间) │ + └───────────────┘ +``` + +### 工作空间资源关系 + +``` + ┌───────────────┐ + │ workspaces │ + │ (工作空间) │ + └───────┬───────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + │ 1:N │ 1:1 │ 1:N + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ teams │ │workspace_quotas│ │ subscriptions │ +│ (团队) │ │ (工作空间配额) │ │ (订阅) │ +└───────┬───────┘ └───────────────┘ └───────────────┘ + │ + │ 1:N + │ + ▼ +┌───────────────┐ +│ groups │ +│ (分组) │ +└───────┬───────┘ + │ + │ 1:N (可选) + │ (分组可以没有环境) + │ + ▼ +┌───────────────┐ +│ environments │ +│ (环境) │ +└───────┬───────┘ + │ + │ N:1 (必须) + │ (环境必须属于团队) + │ + ▼ +┌───────────────┐ +│ teams │ +│ (团队) │ +└───────────────┘ +``` + +### 代理资源关系 + +``` + ┌───────────────┐ + │ workspaces │ + │ (工作空间) │ + └───────┬───────┘ + │ + │ 1:N + │ + ▼ + ┌───────────────┐ + │ proxies │ + │ (代理) │ + │ (工作空间级别) │ + └───────┬───────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + │ N:1 (可选) │ N:M │ N:1 + │ (环境选择代理) │ (可见性控制) │ (所有者) + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ environments │ │proxy_visible_ │ │ users │ +│ (环境) │ │teams │ │ (用户) │ +│ │ │(代理可见团队) │ │ (所有者) │ +└───────────────┘ └───────┬───────┘ └───────────────┘ + │ + │ N:1 + │ + ▼ + ┌───────────────┐ + │ teams │ + │ (团队) │ + └───────────────┘ +``` + +### 权限控制关系 + +``` + ┌───────────────┐ + │ workspaces │ + │ (工作空间) │ + └───────┬───────┘ + │ + │ 1:N + │ + ▼ + ┌───────────────┐ + │ teams │ + │ (团队) │ + └───────┬───────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + │ 1:N │ N:M │ 1:N + │ │ (工作空间级别) │ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ groups │ │team_members │ │group_member_ │ +│ (分组) │ │(团队成员关系) │ │permissions │ +│ │ │(工作空间级别) │ │(分组权限) │ +└───────┬───────┘ └───────┬───────┘ └───────┬───────┘ + │ │ │ + │ N:1 │ N:1 │ N:1 + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ environments │ │ users │ │ users │ +│ (环境) │ │ (用户) │ │ (用户) │ +└───────────────┘ └───────────────┘ └───────────────┘ +``` + +## 实体详细设计 + +### 1. 用户(Users) + +**职责**:系统的基础实体,代表使用系统的个人。 + +**关键属性**: + +- `uuid`:用户唯一标识 +- `id`:业务层用户ID +- 与工作空间的关系:一个用户可以有多个工作空间(作为 Owner) + +**关系**: + +- 1:N → 工作空间(作为 Owner) +- N:M → 团队(通过 team_members,工作空间级别) +- N:M → 分组权限(通过 group_member_permissions,工作空间级别) + +**设计要点**: + +- 用户在不同工作空间中,可能有不同的团队成员身份 +- 团队成员关系是工作空间级别的,不是全局的 + +### 2. 工作空间(Workspaces) + +**职责**:资源隔离的顶层容器,所有资源都归属于工作空间。 + +**关键属性**: + +- `uuid`:工作空间唯一标识 +- `name`:工作空间名称 +- `owner_uuid`:所有者用户 UUID +- `workspace_type`:工作空间类型(personal/team/enterprise) + +**关系**: + +- N:1 → 用户(Owner) +- 1:N → 团队 +- 1:1 → 工作空间配额 +- 1:N → 订阅 +- 1:N → 代理(工作空间级别) + +**设计要点**: + +- 工作空间是计费和配额的基础单元 +- 工作空间内的所有资源共享配额 +- 工作空间 Owner 拥有最高权限 + +### 3. 团队(Teams) + +**职责**:工作空间内的协作组织单元,管理团队成员和团队资源。 + +**关键属性**: + +- `uuid`:团队唯一标识 +- `workspace_uuid`:所属工作空间 UUID +- `name`:团队名称 +- `owner_uuid`:团队所有者 UUID +- `description`:团队描述 + +**关系**: + +- N:1 → 工作空间 +- N:1 → 用户(Owner) +- N:M → 用户(通过 team_members) +- 1:N → 分组(可选,团队可以没有分组) +- 1:N → 环境(直接关联,环境必须属于团队) + +**设计要点**: + +- 团队是权限管理的基础单元 +- 团队成员通过 team_members 表关联 +- 团队 Owner/Admin 可以管理团队内的分组和权限 + +### 4. 分组(Groups) + +**职责**:团队内环境的分类容器,用于环境组织和权限控制(可选功能)。 + +**关键属性**: + +- `uuid`:分组唯一标识 +- `workspace_uuid`:所属工作空间 UUID(冗余,便于查询) +- `team_uuid`:所属团队 UUID +- `name`:分组名称 +- `description`:分组描述 + +**关系**: + +- N:1 → 工作空间 +- N:1 → 团队 +- 1:N → 环境(可选,分组可以没有环境) +- N:M → 用户(通过 group_member_permissions) + +**设计要点**: + +- 分组属于团队,用于对团队内的环境进行分类 +- **分组可以独立存在,不一定要有环境**(分组可以预先创建,等待环境加入) +- 分组权限控制团队成员对分组内环境的访问 +- 分组是可选功能,环境可以不使用分组直接归属团队 + +### 5. 环境(Environments) + +**职责**:系统的核心操作单元,所有业务操作围绕环境展开。 + +**关键属性**: + +- `uuid`:环境唯一标识 +- `workspace_uuid`:所属工作空间 UUID(冗余,便于查询) +- `team_uuid`:所属团队 UUID(必须) +- `group_uuid`:所属分组 UUID(可选,NULL 表示直接归属团队) +- `proxy_uuid`:环境使用的代理 UUID(可选,用户明确选择) +- `name`:环境名称 +- `status`:环境状态 + +**关系**: + +- N:1 → 工作空间 +- N:1 → 团队(必须) +- N:1 → 分组(可选) +- N:1 → 代理(可选) + +**设计要点**: + +- **环境必须直接归属团队**(`team_uuid` NOT NULL) +- **环境可以属于某个分组**(`group_uuid` 可选,NULL 表示未分组) +- **环境直接归属团队时,使用团队级别的权限控制** +- **环境可以设置代理**(`proxy_uuid` 可选),用户从可见的代理列表中选择 +- **没有代理继承概念**,用户必须明确选择代理 + +### 6. 代理(Proxies) + +**职责**:网络代理资源,为环境提供网络访问能力。 + +**关键属性**: + +- `uuid`:代理唯一标识 +- `workspace_uuid`:所属工作空间 UUID(必须) +- `owner_uuid`:代理所有者 UUID(创建者) +- `name`:代理名称 +- `proxy_type`:代理类型 +- `host`、`port`:代理地址 +- `status`:代理状态(active/inactive/testing/error) + +**关系**: + +- N:1 → 工作空间(必须) +- N:1 → 用户(Owner,创建者) +- 1:N → 环境(通过环境选择,可选) +- N:M → 团队(通过 proxy_visible_teams,控制可见性) + +**设计要点**: + +- **代理属于工作空间**,不属于团队(`team_uuid` 不存在) +- **代理默认不可见**,只有所有者可以看到 +- **代理可见性通过关联表控制**:`proxy_visible_teams` 表存储代理对哪些团队可见 +- **代理所有者可以设置可见性**:可以设置代理对特定团队可见 +- **工作空间 Owner 可以看到所有代理**,并可以管理所有代理 +- **创建环境时,代理是可选的**,用户从可见的代理列表中选择 +- **没有继承概念**,用户必须明确选择代理 + +### 7. 团队成员(Team Members) + +**职责**:用户与团队的关联关系,定义用户在团队中的角色(工作空间级别)。 + +**关键属性**: + +- `team_uuid`:团队 UUID +- `workspace_uuid`:工作空间 UUID(冗余,便于查询) +- `user_uuid`:用户 UUID +- `role`:角色(owner/admin/editor/viewer) +- `status`:状态(active/pending/inactive) + +**关系**: + +- N:1 → 团队 +- N:1 → 用户 +- N:1 → 工作空间(冗余,通过 team.workspace_uuid 关联) + +**设计要点**: + +- **团队成员关系是工作空间级别的**:用户在某个工作空间加入团队,只在该工作空间内属于该团队 +- 同一用户在不同工作空间中,可能有不同的团队成员身份和角色 +- 定义用户在团队中的角色和权限 +- Owner/Admin 拥有团队管理权限 +- Editor 可以创建和编辑环境 +- Viewer 只能查看环境 +- `workspace_uuid` 作为冗余字段,便于直接查询工作空间内的团队成员关系 + +### 8. 分组权限(Group Member Permissions) + +**职责**:控制团队成员对分组的访问权限,实现细粒度权限控制。 + +**关键属性**: + +- `group_uuid`:分组 UUID +- `workspace_uuid`:工作空间 UUID(冗余,便于查询) +- `team_uuid`:团队 UUID(冗余,便于查询) +- `user_uuid`:用户 UUID +- `permission_type`:权限类型(read/write/manage) +- `granted_by`:授权者 UUID + +**关系**: + +- N:1 → 分组 +- N:1 → 用户 +- N:1 → 工作空间(冗余) +- N:1 → 团队(冗余) + +**设计要点**: + +- 只有团队成员才能被授予分组权限 +- Owner/Admin 自动拥有所有分组权限(无需显式授权) +- Editor/Viewer 需要显式授权才能访问分组 +- 权限类型:read(查看)、write(创建/编辑)、manage(管理分组) + +### 9. 工作空间配额(Workspace Quotas) + +**职责**:定义工作空间的资源配额限制。 + +**关键属性**: + +- `workspace_uuid`:工作空间 UUID +- `max_environments`:最大环境数 +- `used_environments`:已使用环境数 +- `max_team_members`:最大成员数(所有团队总和) +- `max_proxies`:最大代理数 +- `used_proxies`:已使用代理数 +- `max_rpa_tasks`:最大 RPA 任务数 +- `used_rpa_tasks`:已使用 RPA 任务数 + +**关系**: + +- 1:1 → 工作空间 + +**设计要点**: + +- 配额是工作空间级别的,所有团队共享 +- 配额由订阅套餐决定 +- 配额使用情况实时统计 + +### 10. 订阅(Subscriptions) + +**职责**:工作空间的订阅信息,关联套餐和计费。 + +**关键属性**: + +- `uuid`:订阅唯一标识 +- `workspace_uuid`:工作空间 UUID +- `user_uuid`:订阅者用户 UUID(用于记录) +- `plan_uuid`:套餐 UUID +- `billing_period`:计费周期 +- `status`:订阅状态 + +**关系**: + +- N:1 → 工作空间 +- N:1 → 用户(订阅者) +- N:1 → 套餐 + +**设计要点**: + +- 订阅关联到工作空间,而非用户 +- 工作空间 Owner 负责订阅和计费 +- 订阅决定工作空间的配额 + +## 权限控制体系 + +### 权限层级 + +``` +工作空间级别 + └─ Owner:拥有工作空间的所有权限 + ├─ 管理团队 + ├─ 管理订阅和配额 + └─ 管理工作空间级别的代理 + +团队级别 + └─ Owner/Admin:拥有团队的所有权限 + ├─ 管理团队成员 + ├─ 管理分组 + └─ 管理分组权限 + └─ Editor:可以创建和编辑环境 + └─ Viewer:只能查看环境 + +分组级别 + └─ Manage:可以管理分组(编辑分组、授权用户) + └─ Write:可以创建和编辑分组内的环境 + └─ Read:可以查看分组内的环境 +``` + +### 权限检查流程 + +**重要前提**:所有权限检查都在当前工作空间上下文中进行。 + +**创建环境**: + +1. 检查用户是否在当前工作空间的团队中(`team_members.workspace_uuid = 当前工作空间`) +2. 如果指定了分组(`group_uuid` 不为 NULL): + - 检查用户是否有目标分组的 write 或 manage 权限(在当前工作空间上下文中) +3. 如果未指定分组(`group_uuid` 为 NULL): + - 检查用户是否有团队级别的环境创建权限(Editor/Admin/Owner) +4. 检查工作空间配额是否充足 + +**查看环境**: + +1. 检查用户是否在当前工作空间的团队中 +2. 如果环境有分组(`group_uuid` 不为 NULL): + - 检查用户是否有分组的 read/write/manage 权限(在当前工作空间上下文中) + - Owner/Admin 自动拥有所有分组权限 +3. 如果环境无分组(`group_uuid` 为 NULL): + - 检查用户是否有团队级别的查看权限(所有团队成员都可以查看) + +**管理分组**: + +1. 检查用户是否在当前工作空间的团队中 +2. 检查用户是否是团队 Owner/Admin 或拥有分组的 manage 权限(在当前工作空间上下文中) + +**未分组环境的权限规则**: + +- Owner/Admin:拥有所有权限(创建、编辑、删除) +- Editor:可以创建和编辑环境 +- Viewer:只能查看环境 +- 所有团队成员默认可以查看未分组环境 + +### 工作空间级别的团队成员关系 + +**核心设计**: + +- 用户在某个工作空间加入团队,只在该工作空间内属于该团队 +- 同一用户在不同工作空间中,可能有不同的团队成员身份和角色 +- 团队成员关系通过 `team_members` 表关联,包含 `workspace_uuid` 字段(冗余,便于查询) + +**示例场景**: + +``` +用户A: + - 在工作空间1:团队X的 Owner + - 在工作空间2:团队Y的 Editor + - 在工作空间3:团队Z的 Viewer +``` + +**查询逻辑**: + +- 查询用户在某工作空间的团队:`WHERE user_uuid = $1 AND workspace_uuid = $2` +- 查询团队在某工作空间的成员:`WHERE team_uuid = $1 AND workspace_uuid = $2` +- 通过 `team.workspace_uuid` 可以间接关联,但冗余 `workspace_uuid` 字段可以提高查询性能 + +## 配额管理 + +### 配额层级 + +``` +工作空间配额(workspace_quotas) + └─ 所有团队共享 + └─ 由订阅套餐决定 + └─ 实时统计使用情况 +``` + +### 配额检查 + +**环境配额**: + +- 统计工作空间内所有团队的环境总数 +- 检查 `used_environments < max_environments` + +**代理配额**: + +- 统计工作空间内的代理总数 +- 检查 `used_proxies < max_proxies` + +**成员配额**: + +- 统计工作空间内所有团队的活跃成员总数 +- 检查 `used_team_members < max_team_members` + +## 代理管理 + +### 代理可见性设计 + +**核心设计**: + +- 代理属于工作空间,不属于团队 +- 代理默认不可见,只有所有者可以看到 +- 代理所有者可以设置代理对特定团队可见 +- 通过 `proxy_visible_teams` 关联表控制可见性 + +**可见性规则**: + +``` +代理可见性检查(优先级从高到低): +1. 工作空间 Owner:可以看到所有代理 +2. 代理所有者:可以看到自己的代理(无论是否在可见列表中) +3. 团队成员:只能看到 proxy_visible_teams 中包含其团队的代理 +4. 否则:不可见 +``` + +**数据模型**: + +```sql +-- 代理表 +proxies: + - workspace_uuid(必须) + - owner_uuid(必须,创建者) + - name, proxy_type, host, port 等 + +-- 代理可见团队关联表 +proxy_visible_teams: + - proxy_uuid + - workspace_uuid(冗余,便于查询) + - team_uuid + - UNIQUE (proxy_uuid, team_uuid) +``` + +### 代理使用流程 + +**创建环境时的代理选择**: + +1. 用户选择当前工作空间 +2. 用户选择当前团队 +3. 系统查询用户可见的代理: + - 工作空间 Owner:所有代理 + - 代理所有者:自己的代理 + - 团队成员:`proxy_visible_teams` 中包含其团队的代理 +4. 如果没有任何可见的代理: + - 显示"没有可用的代理" + - 提供"上传新代理"选项 +5. 用户从可见代理列表中选择(可选,代理不是必须的) +6. 环境创建时,`proxy_uuid` 设置为用户选择的代理(如果选择了) + +**代理选择逻辑**: + +- 代理是可选的,创建环境时可以不选择代理 +- 用户从可见的代理列表中选择 +- 没有继承概念,用户必须明确选择 +- 如果代理不可见,用户无法选择 + +## 数据模型设计要点 + +### 1. 冗余字段设计 + +为了优化查询性能,部分表包含冗余字段: + +- `groups.workspace_uuid`:冗余字段,避免 JOIN 查询 +- `environments.workspace_uuid`:冗余字段,便于直接查询工作空间的环境 +- `team_members.workspace_uuid`:冗余字段,便于直接查询工作空间内的团队成员关系(工作空间级别的隔离) +- `group_member_permissions.workspace_uuid`、`team_uuid`:冗余字段,便于权限查询 +- `proxy_visible_teams.workspace_uuid`:冗余字段,便于直接查询工作空间内的代理可见性 + +**工作空间级别的隔离设计**: + +- `team_members` 表包含 `workspace_uuid` 字段(冗余),确保团队成员关系是工作空间级别的 +- 查询时始终需要指定 `workspace_uuid`,确保数据隔离 +- 通过 `team.workspace_uuid` 可以间接关联,但冗余字段提高查询性能 + +### 2. 软删除策略 + +所有表都支持软删除: + +- 使用 `deleted_at` 字段标记删除 +- 查询时默认过滤已删除记录 +- 支持数据恢复 + +### 3. 时间戳管理 + +所有表都包含时间戳字段: + +- `created_at`:创建时间 +- `updated_at`:更新时间(自动更新) +- 部分表包含 `deleted_at`(软删除时间) + +### 4. UUID 主键 + +所有表使用 UUID 作为主键: + +- 全局唯一标识 +- 避免 ID 冲突 +- 支持分布式系统 + +## 设计优势 + +### 1. 清晰的层级结构 + +- 用户 → 工作空间 → 团队 → 分组 → 环境 +- 每一层都有明确的职责和边界 +- 易于理解和维护 + +### 2. 灵活的权限控制 + +- 工作空间级别:Owner 管理 +- 团队级别:角色权限 +- 分组级别:细粒度权限 +- 支持复杂的权限场景 + +### 3. 统一的资源管理 + +- 所有资源归属于工作空间 +- 配额和计费基于工作空间 +- 资源隔离清晰 + +### 4. 良好的扩展性 + +- 支持多工作空间 +- 支持多团队协作 +- 支持细粒度权限控制 +- 易于扩展新功能 + +### 5. 符合业务逻辑 + +- 工作空间 = 企业账户/项目空间 +- 团队 = 部门/协作组 +- 分组 = 环境分类 +- 环境 = 核心操作单元 + +## 分组与环境的关系设计 + +### 设计原则 + +1. **环境直接归属团队**:环境必须属于某个团队(`team_uuid` NOT NULL),这是环境的基础归属关系。 + +2. **分组是可选分类容器**:环境可以可选归属分组(`group_uuid` 可选),分组用于环境分类和权限控制,但不是必需的。 + +3. **分组可以独立存在**:分组可以预先创建,不一定要有环境。团队管理者可以创建分组并设置权限,等待环境加入。 + +4. **灵活的权限控制**: + - 有分组的环境:使用分组权限控制 + - 无分组的环境:使用团队级别权限控制 + +### 使用场景 + +**场景1:环境直接归属团队(无分组)** + +``` +团队 + └─ 环境1(group_uuid = NULL) + └─ 环境2(group_uuid = NULL) + └─ 环境3(group_uuid = NULL) +``` + +- 适用于简单场景,不需要复杂分类 +- 权限控制基于团队角色(Owner/Admin/Editor/Viewer) +- 所有团队成员可以查看,Editor 及以上可以创建/编辑 + +**场景2:环境归属分组** + +``` +团队 + ├─ 分组A + │ └─ 环境1(group_uuid = 分组A) + │ └─ 环境2(group_uuid = 分组A) + └─ 分组B + └─ 环境3(group_uuid = 分组B) +``` + +- 适用于需要环境分类的场景 +- 权限控制基于分组权限(read/write/manage) +- 可以实现细粒度的权限控制 + +**场景3:混合模式** + +``` +团队 + ├─ 环境1(group_uuid = NULL,直接归属团队) + ├─ 分组A + │ └─ 环境2(group_uuid = 分组A) + └─ 分组B(空分组,等待环境加入) +``` + +- 部分环境使用分组,部分环境直接归属团队 +- 提供最大的灵活性 + +### 降级方案 + +如果某些场景需要强制使用分组,可以采用以下降级方案: + +1. **默认分组**:团队创建时自动创建一个"默认分组" +2. **强制分组**:创建环境时,如果未指定分组,自动归属默认分组 +3. **透明处理**:前端可以隐藏默认分组,让用户感觉环境直接归属团队 + +但更推荐的设计是:**环境可以直接归属团队,分组是可选的分类功能**。 + +## 代理可见性管理 + +### 核心设计 + +**代理归属**: + +- 代理属于工作空间,不属于团队(`team_uuid` 不存在) +- 代理有所有者(`owner_uuid`),创建者即为所有者 +- 代理默认不可见,只有所有者可以看到 + +**可见性控制**: + +- 代理所有者可以设置代理对特定团队可见(通过 `proxy_visible_teams` 关联表) +- 工作空间 Owner 可以看到所有代理,并可以管理所有代理 +- 团队成员只能看到 `proxy_visible_teams` 中包含其团队的代理 + +**数据模型**: + +```sql +-- 代理表 +proxies: + - uuid + - workspace_uuid(必须) + - owner_uuid(必须,创建者) + - name, proxy_type, host, port, status 等 + +-- 代理可见团队关联表 +proxy_visible_teams: + - proxy_uuid + - workspace_uuid(冗余,便于查询) + - team_uuid + - UNIQUE (proxy_uuid, team_uuid) + - FOREIGN KEY (proxy_uuid) REFERENCES proxies(uuid) ON DELETE CASCADE + - FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE +``` + +**可见性检查逻辑**(优先级从高到低): + +1. **工作空间 Owner**:可以看到所有代理 +2. **代理所有者**:可以看到自己的代理(无论是否在可见列表中) +3. **团队成员**:只能看到 `proxy_visible_teams` 中包含其团队的代理 +4. **否则**:不可见 + +### 使用流程 + +**创建环境时的代理选择**: + +1. 用户选择当前工作空间 +2. 用户选择当前团队 +3. 系统查询用户可见的代理: + - 工作空间 Owner:所有代理 + - 代理所有者:自己的代理 + - 团队成员:`proxy_visible_teams` 中包含其团队的代理 +4. 如果没有任何可见的代理: + - 显示"没有可用的代理" + - 提供"上传新代理"选项 +5. 用户从可见代理列表中选择(可选,代理不是必须的) +6. 环境创建时,`proxy_uuid` 设置为用户选择的代理(如果选择了) + +**代理管理**: + +- 代理所有者可以添加/移除团队的可见性 +- 通过 `proxy_visible_teams` 表管理可见性 +- 团队删除时,关联的可见性记录自动删除(ON DELETE CASCADE) +- 代理删除时,关联的可见性记录自动删除(ON DELETE CASCADE) + +**设计要点**: + +- **代理是可选的**,创建环境时可以不选择代理 +- **没有继承概念**,用户必须明确选择代理 +- **代理默认不可见**,需要主动设置为可见 +- **管理员创建代理后,需要设置为可见,团队成员才能看到** +- **团队成员创建环境时,只看到可见的代理列表** + +## 总结 + +本架构设计通过"用户-工作空间-团队-分组-环境"的多层级结构,实现了灵活的资源管理、权限控制和计费体系。工作空间作为资源隔离的顶层容器,团队作为协作组织单元,分组作为可选的环境分类容器,环境作为核心操作单元直接归属团队,形成了一个清晰、可扩展的系统架构。 + +**核心设计特点**: + +- 环境必须直接归属团队,分组是可选的分类功能 +- 分组可以独立存在,不一定要有环境 +- 灵活的权限控制:有分组用分组权限,无分组用团队权限 +- 支持混合使用:部分环境使用分组,部分环境直接归属团队 + +该架构既支持个人用户的使用场景,也支持企业级的多团队协作场景,同时通过细粒度的权限控制,确保了资源的安全性和可控性。 diff --git a/server/migrations/20250101000001_create_users.sql b/server/migrations/20250101000001_create_users.sql new file mode 100644 index 00000000..9e019ece --- /dev/null +++ b/server/migrations/20250101000001_create_users.sql @@ -0,0 +1,29 @@ +-- 创建 users 表 +-- 用户基础信息表,存储用户的基础标识信息 + +CREATE TABLE IF NOT EXISTS users ( + uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id VARCHAR(255) NOT NULL UNIQUE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at); + +-- 创建更新时间触发器 +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + + + + diff --git a/server/migrations/20250101000002_create_user_infos.sql b/server/migrations/20250101000002_create_user_infos.sql new file mode 100644 index 00000000..774c504c --- /dev/null +++ b/server/migrations/20250101000002_create_user_infos.sql @@ -0,0 +1,31 @@ +-- 创建 user_infos 表 +-- 用户详细信息表,存储用户的详细业务信息 + +CREATE TABLE IF NOT EXISTS user_infos ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL UNIQUE, + nickname VARCHAR(255), + email VARCHAR(255) NOT NULL UNIQUE, + phone VARCHAR(50), + password VARCHAR(255) NOT NULL, + avatar_hash VARCHAR(255), + status VARCHAR(50) NOT NULL DEFAULT 'active', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT fk_user_infos_user_uuid FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_infos_user_uuid ON user_infos(user_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_infos_email ON user_infos(email); +CREATE INDEX IF NOT EXISTS idx_user_infos_deleted_at ON user_infos(deleted_at); +CREATE INDEX IF NOT EXISTS idx_user_infos_status ON user_infos(status); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_infos_updated_at BEFORE UPDATE ON user_infos + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + + + + diff --git a/server/migrations/20250101000003_create_machine_users_table.sql b/server/migrations/20250101000003_create_machine_users_table.sql new file mode 100644 index 00000000..a5ebc5c6 --- /dev/null +++ b/server/migrations/20250101000003_create_machine_users_table.sql @@ -0,0 +1,41 @@ +-- Add migration script here +-- public.machine_users definition + +-- Drop table +-- DROP TABLE public.machine_users; + +CREATE TABLE public.machine_users ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + machine_code varchar(255) NOT NULL, -- 机器码 + user_uuid uuid NULL, -- 用户UUID + platform varchar(50) NULL, -- 平台 + bind_time timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 绑定时间 + status varchar(20) DEFAULT 'active'::character varying NOT NULL, -- 状态 + version_info jsonb NULL, -- 版本信息(JSON格式) + hardware_hash varchar(255) NULL, -- 硬件哈希 + hardware_raw text NULL, -- 硬件原始信息 + allow boolean DEFAULT true NOT NULL, -- 是否允许使用 + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + updated_at timestamptz NULL, -- 更新时间 + CONSTRAINT machine_users_pk PRIMARY KEY (id) +); + +CREATE INDEX machine_users_machine_code_idx ON public.machine_users USING btree (machine_code); +CREATE INDEX machine_users_user_uuid_idx ON public.machine_users USING btree (user_uuid); +CREATE INDEX machine_users_status_idx ON public.machine_users USING btree (status); +CREATE INDEX machine_users_platform_idx ON public.machine_users USING btree (platform); + +-- Column comments +COMMENT ON COLUMN public.machine_users.id IS '主键ID'; +COMMENT ON COLUMN public.machine_users.machine_code IS '机器码'; +COMMENT ON COLUMN public.machine_users.user_uuid IS '用户UUID'; +COMMENT ON COLUMN public.machine_users.platform IS '平台'; +COMMENT ON COLUMN public.machine_users.bind_time IS '绑定时间'; +COMMENT ON COLUMN public.machine_users.status IS '状态'; +COMMENT ON COLUMN public.machine_users.version_info IS '版本信息(JSON格式,存储versions表的信息)'; +COMMENT ON COLUMN public.machine_users.hardware_hash IS '硬件哈希'; +COMMENT ON COLUMN public.machine_users.hardware_raw IS '硬件原始信息'; +COMMENT ON COLUMN public.machine_users.allow IS '是否允许使用'; +COMMENT ON COLUMN public.machine_users.created_at IS '创建时间'; +COMMENT ON COLUMN public.machine_users.updated_at IS '更新时间'; + diff --git a/server/migrations/20250101000004_create_version_types_table.sql b/server/migrations/20250101000004_create_version_types_table.sql new file mode 100644 index 00000000..3ebc8d93 --- /dev/null +++ b/server/migrations/20250101000004_create_version_types_table.sql @@ -0,0 +1,26 @@ +-- Add migration script here +-- public.version_types definition + +-- Drop table +-- DROP TABLE public.version_types; + +CREATE TABLE public.version_types ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + type_code varchar(50) NOT NULL, -- 类型代码 + type_name varchar(100) NOT NULL, -- 类型名称 + description text NULL, -- 描述 + sort_order int4 DEFAULT 0 NULL, -- 排序顺序 + is_active bool DEFAULT true NOT NULL, -- 是否激活 + CONSTRAINT version_types_pk PRIMARY KEY (id), + CONSTRAINT version_types_type_code_unique UNIQUE (type_code) +); +CREATE UNIQUE INDEX version_types_type_code_idx ON public.version_types USING btree (type_code); + +-- Column comments +COMMENT ON COLUMN public.version_types.id IS '主键ID'; +COMMENT ON COLUMN public.version_types.type_code IS '类型代码'; +COMMENT ON COLUMN public.version_types.type_name IS '类型名称'; +COMMENT ON COLUMN public.version_types.description IS '描述'; +COMMENT ON COLUMN public.version_types.sort_order IS '排序顺序'; +COMMENT ON COLUMN public.version_types.is_active IS '是否激活'; + diff --git a/server/migrations/20250101000005_create_versions_table.sql b/server/migrations/20250101000005_create_versions_table.sql new file mode 100644 index 00000000..10331077 --- /dev/null +++ b/server/migrations/20250101000005_create_versions_table.sql @@ -0,0 +1,53 @@ +-- Add migration script here +-- public.versions definition + +-- Drop table +-- DROP TABLE public.versions; + +CREATE TABLE public.versions ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + type_id int4 NOT NULL, -- 版本类型ID + resource_name varchar(100) NOT NULL, -- 资源名称 + version varchar(50) NOT NULL, -- 版本号 + name varchar(200) NULL, -- 版本名称 + notes text NULL, -- 更新说明 + platform varchar(50) NULL, -- 平台 + url text NULL, -- 下载链接 + hash varchar(255) NULL, -- 文件哈希 + signature varchar(255) NULL, -- 签名 + install_path text NULL, -- 安装路径 + file_size int4 NULL, -- 文件大小 + is_latest bool DEFAULT false NOT NULL, -- 是否最新版本 + status varchar(20) DEFAULT 'active'::character varying NOT NULL, -- 状态 + pub_date timestamptz NULL, -- 发布时间 + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + updated_at timestamptz NULL, -- 更新时间 + deleted_at timestamptz NULL, -- 删除时间 + CONSTRAINT versions_pk PRIMARY KEY (id), + CONSTRAINT versions_type_id_fk FOREIGN KEY (type_id) REFERENCES public.version_types(id) +); +CREATE INDEX versions_type_id_idx ON public.versions USING btree (type_id); +CREATE INDEX versions_resource_name_idx ON public.versions USING btree (resource_name); +CREATE INDEX versions_status_idx ON public.versions USING btree (status); +CREATE INDEX versions_is_latest_idx ON public.versions USING btree (is_latest); + +-- Column comments +COMMENT ON COLUMN public.versions.id IS '主键ID'; +COMMENT ON COLUMN public.versions.type_id IS '版本类型ID'; +COMMENT ON COLUMN public.versions.resource_name IS '资源名称'; +COMMENT ON COLUMN public.versions.version IS '版本号'; +COMMENT ON COLUMN public.versions.name IS '版本名称'; +COMMENT ON COLUMN public.versions.notes IS '更新说明'; +COMMENT ON COLUMN public.versions.platform IS '平台'; +COMMENT ON COLUMN public.versions.url IS '下载链接'; +COMMENT ON COLUMN public.versions.hash IS '文件哈希'; +COMMENT ON COLUMN public.versions.signature IS '签名'; +COMMENT ON COLUMN public.versions.install_path IS '安装路径'; +COMMENT ON COLUMN public.versions.file_size IS '文件大小'; +COMMENT ON COLUMN public.versions.is_latest IS '是否最新版本'; +COMMENT ON COLUMN public.versions.status IS '状态'; +COMMENT ON COLUMN public.versions.pub_date IS '发布时间'; +COMMENT ON COLUMN public.versions.created_at IS '创建时间'; +COMMENT ON COLUMN public.versions.updated_at IS '更新时间'; +COMMENT ON COLUMN public.versions.deleted_at IS '删除时间'; + diff --git a/server/migrations/20250101000006_create_gray_releases_table.sql b/server/migrations/20250101000006_create_gray_releases_table.sql new file mode 100644 index 00000000..7b73ed25 --- /dev/null +++ b/server/migrations/20250101000006_create_gray_releases_table.sql @@ -0,0 +1,44 @@ +-- Add migration script here +-- public.gray_releases definition + +-- Drop table +-- DROP TABLE public.gray_releases; + +CREATE TABLE public.gray_releases ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + name varchar(100) NOT NULL, -- 灰度名称 + description text NULL, -- 描述 + platform varchar(50) NOT NULL, -- 平台 + status varchar(20) DEFAULT 'pending'::character varying NOT NULL, -- 状态 + start_time timestamptz NOT NULL, -- 开始时间 + end_time timestamptz NULL, -- 结束时间 + max_machines int4 NULL, -- 最大配额(NULL=不限制) + allocated_count int4 DEFAULT 0 NOT NULL, -- 已分配数量 + priority int4 DEFAULT 0 NOT NULL, -- 优先级 + strategy_type varchar(50) NULL, -- 策略类型 + strategy_config jsonb NULL, -- 策略配置(JSON格式) + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + updated_at timestamptz NULL, -- 更新时间 + CONSTRAINT gray_releases_pk PRIMARY KEY (id) +); +CREATE INDEX gray_releases_status_idx ON public.gray_releases USING btree (status); +CREATE INDEX gray_releases_platform_idx ON public.gray_releases USING btree (platform); +CREATE INDEX gray_releases_time_idx ON public.gray_releases USING btree (start_time, end_time); +CREATE INDEX gray_releases_priority_idx ON public.gray_releases USING btree (priority); + +-- Column comments +COMMENT ON COLUMN public.gray_releases.id IS '主键ID'; +COMMENT ON COLUMN public.gray_releases.name IS '灰度名称'; +COMMENT ON COLUMN public.gray_releases.description IS '描述'; +COMMENT ON COLUMN public.gray_releases.platform IS '平台'; +COMMENT ON COLUMN public.gray_releases.status IS '状态'; +COMMENT ON COLUMN public.gray_releases.start_time IS '开始时间'; +COMMENT ON COLUMN public.gray_releases.end_time IS '结束时间'; +COMMENT ON COLUMN public.gray_releases.max_machines IS '最大配额(NULL=不限制)'; +COMMENT ON COLUMN public.gray_releases.allocated_count IS '已分配数量'; +COMMENT ON COLUMN public.gray_releases.priority IS '优先级'; +COMMENT ON COLUMN public.gray_releases.strategy_type IS '策略类型'; +COMMENT ON COLUMN public.gray_releases.strategy_config IS '策略配置(JSON格式)'; +COMMENT ON COLUMN public.gray_releases.created_at IS '创建时间'; +COMMENT ON COLUMN public.gray_releases.updated_at IS '更新时间'; + diff --git a/server/migrations/20250101000007_create_gray_resources_table.sql b/server/migrations/20250101000007_create_gray_resources_table.sql new file mode 100644 index 00000000..22e9d1c2 --- /dev/null +++ b/server/migrations/20250101000007_create_gray_resources_table.sql @@ -0,0 +1,24 @@ +-- Add migration script here +-- public.gray_resources definition + +-- Drop table +-- DROP TABLE public.gray_resources; + +CREATE TABLE public.gray_resources ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + gray_release_id int4 NOT NULL, -- 灰度发布ID + version_id int4 NOT NULL, -- 版本ID + sort_order int4 DEFAULT 0 NOT NULL, -- 排序顺序 + CONSTRAINT gray_resources_pk PRIMARY KEY (id), + CONSTRAINT gray_resources_gray_release_id_fk FOREIGN KEY (gray_release_id) REFERENCES public.gray_releases(id) ON DELETE CASCADE, + CONSTRAINT gray_resources_version_id_fk FOREIGN KEY (version_id) REFERENCES public.versions(id) +); +CREATE INDEX gray_resources_gray_release_id_idx ON public.gray_resources USING btree (gray_release_id); +CREATE INDEX gray_resources_version_id_idx ON public.gray_resources USING btree (version_id); + +-- Column comments +COMMENT ON COLUMN public.gray_resources.id IS '主键ID'; +COMMENT ON COLUMN public.gray_resources.gray_release_id IS '灰度发布ID'; +COMMENT ON COLUMN public.gray_resources.version_id IS '版本ID'; +COMMENT ON COLUMN public.gray_resources.sort_order IS '排序顺序'; + diff --git a/server/migrations/20250101000008_create_machine_gray_allocations_table.sql b/server/migrations/20250101000008_create_machine_gray_allocations_table.sql new file mode 100644 index 00000000..feac000c --- /dev/null +++ b/server/migrations/20250101000008_create_machine_gray_allocations_table.sql @@ -0,0 +1,33 @@ +-- Add migration script here +-- public.machine_gray_allocations definition + +-- Drop table +-- DROP TABLE public.machine_gray_allocations; + +CREATE TABLE public.machine_gray_allocations ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + machine_code varchar(255) NOT NULL, -- 机器码 + gray_release_id int4 NOT NULL, -- 灰度发布ID + allocated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 分配时间 + effective_time timestamptz NULL, -- 生效时间 + status varchar(20) DEFAULT 'active'::character varying NOT NULL, -- 状态 + notes text NULL, -- 备注 + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + CONSTRAINT machine_gray_allocations_pk PRIMARY KEY (id), + CONSTRAINT machine_gray_allocations_machine_gray_unique UNIQUE (machine_code, gray_release_id), + CONSTRAINT machine_gray_allocations_gray_release_id_fk FOREIGN KEY (gray_release_id) REFERENCES public.gray_releases(id) +); +CREATE INDEX machine_gray_allocations_machine_code_idx ON public.machine_gray_allocations USING btree (machine_code); +CREATE INDEX machine_gray_allocations_gray_release_id_idx ON public.machine_gray_allocations USING btree (gray_release_id); +CREATE INDEX machine_gray_allocations_status_idx ON public.machine_gray_allocations USING btree (status); + +-- Column comments +COMMENT ON COLUMN public.machine_gray_allocations.id IS '主键ID'; +COMMENT ON COLUMN public.machine_gray_allocations.machine_code IS '机器码'; +COMMENT ON COLUMN public.machine_gray_allocations.gray_release_id IS '灰度发布ID'; +COMMENT ON COLUMN public.machine_gray_allocations.allocated_at IS '分配时间'; +COMMENT ON COLUMN public.machine_gray_allocations.effective_time IS '生效时间'; +COMMENT ON COLUMN public.machine_gray_allocations.status IS '状态'; +COMMENT ON COLUMN public.machine_gray_allocations.notes IS '备注'; +COMMENT ON COLUMN public.machine_gray_allocations.created_at IS '创建时间'; + diff --git a/server/migrations/20250101000009_create_maintenances_table.sql b/server/migrations/20250101000009_create_maintenances_table.sql new file mode 100644 index 00000000..b7038baf --- /dev/null +++ b/server/migrations/20250101000009_create_maintenances_table.sql @@ -0,0 +1,33 @@ +-- Add migration script here +-- public.maintenances definition + +-- Drop table +-- DROP TABLE public.maintenances; + +CREATE TABLE public.maintenances ( + id int8 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START 1 CACHE 1 NO CYCLE) NOT NULL, + name varchar(100) NOT NULL, -- 维护名称 + description text NULL, -- 维护描述 + status varchar(20) NOT NULL, -- 状态 + start_time timestamptz NOT NULL, -- 开始时间 + end_time timestamptz NOT NULL, -- 结束时间 + maintenance_type varchar(20) NOT NULL, -- 维护类型 + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 更新时间 + CONSTRAINT maintenances_pk PRIMARY KEY (id) +); +CREATE INDEX maintenances_status_idx ON public.maintenances USING btree (status); +CREATE INDEX maintenances_time_idx ON public.maintenances USING btree (start_time, end_time); +CREATE INDEX maintenances_type_idx ON public.maintenances USING btree (maintenance_type); + +-- Column comments +COMMENT ON COLUMN public.maintenances.id IS '主键ID'; +COMMENT ON COLUMN public.maintenances.name IS '维护名称'; +COMMENT ON COLUMN public.maintenances.description IS '维护描述'; +COMMENT ON COLUMN public.maintenances.status IS '状态'; +COMMENT ON COLUMN public.maintenances.start_time IS '开始时间'; +COMMENT ON COLUMN public.maintenances.end_time IS '结束时间'; +COMMENT ON COLUMN public.maintenances.maintenance_type IS '维护类型'; +COMMENT ON COLUMN public.maintenances.created_at IS '创建时间'; +COMMENT ON COLUMN public.maintenances.updated_at IS '更新时间'; + diff --git a/server/migrations/20250101000010_create_strategy_types_table.sql b/server/migrations/20250101000010_create_strategy_types_table.sql new file mode 100644 index 00000000..4ff64f56 --- /dev/null +++ b/server/migrations/20250101000010_create_strategy_types_table.sql @@ -0,0 +1,31 @@ +-- Add migration script here +-- public.strategy_types definition + +-- Drop table +-- DROP TABLE public.strategy_types; + +CREATE TABLE public.strategy_types ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + code varchar(50) NOT NULL, -- 策略代码(对应 Rust 枚举值) + name varchar(100) NOT NULL, -- 策略名称 + category varchar(50) NULL, -- 分类 + description text NULL, -- 描述 + processor_type varchar(50) NOT NULL, -- 处理器类型(对应 ProcessorType 枚举) + config_schema text NULL, -- 配置JSON Schema + is_active bool DEFAULT true NOT NULL, -- 是否激活 + CONSTRAINT strategy_types_pk PRIMARY KEY (id), + CONSTRAINT strategy_types_code_unique UNIQUE (code) +); +CREATE UNIQUE INDEX strategy_types_code_idx ON public.strategy_types USING btree (code); +CREATE INDEX strategy_types_category_idx ON public.strategy_types USING btree (category); + +-- Column comments +COMMENT ON COLUMN public.strategy_types.id IS '主键ID'; +COMMENT ON COLUMN public.strategy_types.code IS '策略代码(对应 Rust 枚举值)'; +COMMENT ON COLUMN public.strategy_types.name IS '策略名称'; +COMMENT ON COLUMN public.strategy_types.category IS '分类'; +COMMENT ON COLUMN public.strategy_types.description IS '描述'; +COMMENT ON COLUMN public.strategy_types.processor_type IS '处理器类型(对应 ProcessorType 枚举)'; +COMMENT ON COLUMN public.strategy_types.config_schema IS '配置JSON Schema'; +COMMENT ON COLUMN public.strategy_types.is_active IS '是否激活'; + diff --git a/server/migrations/20250101000011_insert_strategy_types_data.sql b/server/migrations/20250101000011_insert_strategy_types_data.sql new file mode 100644 index 00000000..da965532 --- /dev/null +++ b/server/migrations/20250101000011_insert_strategy_types_data.sql @@ -0,0 +1,63 @@ +-- Add migration script here +-- 初始化灰度策略类型数据(仅过滤类策略,不包含标签相关策略) + +-- 插入过滤类策略 +INSERT INTO public.strategy_types (code, name, category, description, processor_type, config_schema, is_active) VALUES + ( + 'filter_whitelist', + '白名单过滤', + 'filter', + '基于机器码白名单的灰度策略,只对白名单中的机器生效', + 'filter_whitelist', + '{ + "type": "object", + "properties": { + "machines": { + "type": "array", + "items": {"type": "string"}, + "description": "机器码列表" + } + }, + "required": ["machines"] + }', + true + ), + ( + 'filter_percentage', + '百分比过滤', + 'filter', + '基于哈希百分比的一致性分配,按百分比随机分配机器到灰度', + 'filter_percentage', + '{ + "type": "object", + "properties": { + "percent": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "百分比 (0-100)" + } + }, + "required": ["percent"] + }', + true + ), + ( + 'filter_random', + '随机过滤', + 'filter', + '基于随机种子的随机分配策略', + 'filter_random', + '{ + "type": "object", + "properties": { + "seed": { + "type": "integer", + "description": "随机种子" + } + }, + "required": ["seed"] + }', + true + ); + diff --git a/server/migrations/20250120000001_create_teams.sql b/server/migrations/20250120000001_create_teams.sql new file mode 100644 index 00000000..187d2779 --- /dev/null +++ b/server/migrations/20250120000001_create_teams.sql @@ -0,0 +1,36 @@ +-- 创建 teams 表 +-- 团队/工作空间表 + +CREATE TABLE IF NOT EXISTS teams ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + name VARCHAR(255) NOT NULL, + description TEXT, + -- 所有者 + owner_uuid UUID NOT NULL, + avatar_hash VARCHAR(255), + -- 配额限制 + max_members INT NOT NULL DEFAULT 10, + max_environments INT NOT NULL DEFAULT 100, + max_proxies INT NOT NULL DEFAULT 100, + -- 【关联】团队默认代理(外键在 proxies 表创建后添加) + default_proxy_uuid UUID, + -- 状态 + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_teams_owner FOREIGN KEY (owner_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_teams_owner_uuid ON teams(owner_uuid); +CREATE INDEX idx_teams_status ON teams(status); +CREATE INDEX idx_teams_deleted_at ON teams(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_teams_updated_at BEFORE UPDATE ON teams + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000002_create_team_members.sql b/server/migrations/20250120000002_create_team_members.sql new file mode 100644 index 00000000..55732ef9 --- /dev/null +++ b/server/migrations/20250120000002_create_team_members.sql @@ -0,0 +1,39 @@ +-- 创建 team_members 表 +-- 团队成员关联表(用户 ↔ 团队,多对多) + +CREATE TABLE IF NOT EXISTS team_members ( + id SERIAL PRIMARY KEY, + team_uuid UUID NOT NULL, + user_uuid UUID NOT NULL, + -- 角色: owner, admin, editor, viewer + role VARCHAR(50) NOT NULL DEFAULT 'viewer', + -- 加入信息 + joined_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + invited_by UUID, + -- 统计字段(冗余,提高查询性能) + environment_count INT NOT NULL DEFAULT 0, + group_count INT NOT NULL DEFAULT 0, + -- 状态 + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_team_members_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_team_members_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT fk_team_members_invited_by FOREIGN KEY (invited_by) REFERENCES users(uuid), + -- 唯一约束:用户在同一团队只能有一条记录 + CONSTRAINT uk_team_members UNIQUE (team_uuid, user_uuid) +); + +-- 创建索引 +CREATE INDEX idx_team_members_team_uuid ON team_members(team_uuid); +CREATE INDEX idx_team_members_user_uuid ON team_members(user_uuid); +CREATE INDEX idx_team_members_role ON team_members(role); +CREATE INDEX idx_team_members_status ON team_members(status); + +-- 创建更新时间触发器 +CREATE TRIGGER update_team_members_updated_at BEFORE UPDATE ON team_members + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000003_create_team_invitations.sql b/server/migrations/20250120000003_create_team_invitations.sql new file mode 100644 index 00000000..66fd5582 --- /dev/null +++ b/server/migrations/20250120000003_create_team_invitations.sql @@ -0,0 +1,37 @@ +-- 创建 team_invitations 表 +-- 团队邀请表 + +CREATE TABLE IF NOT EXISTS team_invitations ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + team_uuid UUID NOT NULL, + email VARCHAR(255) NOT NULL, + -- 角色 + role VARCHAR(50) NOT NULL DEFAULT 'viewer', + -- 邀请者 + invited_by UUID NOT NULL, + -- 邀请链接 + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + -- 状态: pending, accepted, rejected, expired, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'pending', + accepted_at TIMESTAMP WITH TIME ZONE, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_invitations_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_invitations_invited_by FOREIGN KEY (invited_by) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_invitations_team_uuid ON team_invitations(team_uuid); +CREATE INDEX idx_invitations_email ON team_invitations(email); +CREATE INDEX idx_invitations_token ON team_invitations(token); +CREATE INDEX idx_invitations_status ON team_invitations(status); +CREATE INDEX idx_invitations_expires_at ON team_invitations(expires_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_team_invitations_updated_at BEFORE UPDATE ON team_invitations + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000004_create_login_history.sql b/server/migrations/20250120000004_create_login_history.sql new file mode 100644 index 00000000..c3f17f52 --- /dev/null +++ b/server/migrations/20250120000004_create_login_history.sql @@ -0,0 +1,29 @@ +-- 创建 login_history 表 +-- 登录历史表(安全审计) + +CREATE TABLE IF NOT EXISTS login_history ( + id BIGSERIAL PRIMARY KEY, + user_uuid UUID NOT NULL, + -- 登录信息 + ip_address VARCHAR(45) NOT NULL, + device_info VARCHAR(255), + user_agent TEXT, + -- 位置信息(通过 IP 解析) + location VARCHAR(255), + country VARCHAR(100), + city VARCHAR(100), + -- 结果 + success BOOLEAN NOT NULL DEFAULT TRUE, + failure_reason VARCHAR(255), + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_login_history_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_login_history_user_uuid ON login_history(user_uuid); +CREATE INDEX idx_login_history_created_at ON login_history(created_at); +CREATE INDEX idx_login_history_ip ON login_history(ip_address); +CREATE INDEX idx_login_history_success ON login_history(success); + diff --git a/server/migrations/20250120000005_alter_user_infos_add_current_team.sql b/server/migrations/20250120000005_alter_user_infos_add_current_team.sql new file mode 100644 index 00000000..d29b0773 --- /dev/null +++ b/server/migrations/20250120000005_alter_user_infos_add_current_team.sql @@ -0,0 +1,22 @@ +-- 修改 user_infos 表,添加当前团队字段 +-- 用于团队切换功能 + +ALTER TABLE user_infos +ADD COLUMN IF NOT EXISTS current_team_uuid UUID; + +-- 添加外键约束(如果不存在) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_user_infos_current_team' + ) THEN + ALTER TABLE user_infos + ADD CONSTRAINT fk_user_infos_current_team + FOREIGN KEY (current_team_uuid) REFERENCES teams(uuid) ON DELETE SET NULL; + END IF; +END $$; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_user_infos_current_team ON user_infos(current_team_uuid); + diff --git a/server/migrations/20250120000006_create_groups.sql b/server/migrations/20250120000006_create_groups.sql new file mode 100644 index 00000000..fc868b28 --- /dev/null +++ b/server/migrations/20250120000006_create_groups.sql @@ -0,0 +1,39 @@ +-- 创建 groups 表 +-- 环境分组表 + +CREATE TABLE IF NOT EXISTS groups ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 基础信息 + name VARCHAR(255) NOT NULL, + description TEXT, + color VARCHAR(50) DEFAULT 'gray', + sort_order INT DEFAULT 0, + -- 【关联】分组默认代理(外键在 proxies 表创建后添加) + default_proxy_uuid UUID, + -- 创建者 + created_by UUID, + -- 统计字段(计算字段) + environments_count INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_groups_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_groups_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid), + CONSTRAINT fk_groups_created_by FOREIGN KEY (created_by) REFERENCES users(uuid) + -- 注意: default_proxy_uuid 的外键需要在 proxies 表创建后添加 +); + +-- 创建索引 +CREATE INDEX idx_groups_user_uuid ON groups(user_uuid); +CREATE INDEX idx_groups_team_uuid ON groups(team_uuid); +CREATE INDEX idx_groups_deleted_at ON groups(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_groups_updated_at BEFORE UPDATE ON groups + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000007_create_tags.sql b/server/migrations/20250120000007_create_tags.sql new file mode 100644 index 00000000..c7d9c638 --- /dev/null +++ b/server/migrations/20250120000007_create_tags.sql @@ -0,0 +1,31 @@ +-- 创建 tags 表 +-- 标签表 + +CREATE TABLE IF NOT EXISTS tags ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + name VARCHAR(100) NOT NULL, + color VARCHAR(50) DEFAULT 'gray', + sort_order INT DEFAULT 0, + -- 统计字段(计算字段,定期更新或触发器维护) + environments_count INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_tags_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_tags_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_tags_user_uuid ON tags(user_uuid); +CREATE INDEX idx_tags_team_uuid ON tags(team_uuid); +CREATE INDEX idx_tags_deleted_at ON tags(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_tags_updated_at BEFORE UPDATE ON tags + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000008_create_proxies.sql b/server/migrations/20250120000008_create_proxies.sql new file mode 100644 index 00000000..eac8e32a --- /dev/null +++ b/server/migrations/20250120000008_create_proxies.sql @@ -0,0 +1,49 @@ +-- 创建 proxies 表 +-- 代理服务器表 + +CREATE TABLE IF NOT EXISTS proxies ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 基础信息 + name VARCHAR(255) NOT NULL, + host VARCHAR(255) NOT NULL, + port INT NOT NULL, + proxy_type VARCHAR(50) NOT NULL DEFAULT 'http', + -- 认证信息 + username VARCHAR(255), + password_encrypted TEXT, + -- SSH 类型额外字段 + ssh_key_encrypted TEXT, + ssh_passphrase_encrypted TEXT, + -- 地理位置信息 + country VARCHAR(100), + city VARCHAR(100), + -- 状态 + status VARCHAR(50) NOT NULL DEFAULT 'unknown', + latency INT, + last_check_ip VARCHAR(45), + last_checked_at TIMESTAMP WITH TIME ZONE, + -- 统计 + usage_count INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_proxies_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_proxies_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_proxies_user_uuid ON proxies(user_uuid); +CREATE INDEX idx_proxies_team_uuid ON proxies(team_uuid); +CREATE INDEX idx_proxies_proxy_type ON proxies(proxy_type); +CREATE INDEX idx_proxies_status ON proxies(status); +CREATE INDEX idx_proxies_deleted_at ON proxies(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_proxies_updated_at BEFORE UPDATE ON proxies + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000009_create_proxy_health_checks.sql b/server/migrations/20250120000009_create_proxy_health_checks.sql new file mode 100644 index 00000000..8484ec24 --- /dev/null +++ b/server/migrations/20250120000009_create_proxy_health_checks.sql @@ -0,0 +1,23 @@ +-- 创建 proxy_health_checks 表 +-- 代理健康检查记录表 + +CREATE TABLE IF NOT EXISTS proxy_health_checks ( + id BIGSERIAL PRIMARY KEY, + proxy_uuid UUID NOT NULL, + -- 检查结果 + status VARCHAR(50) NOT NULL, + latency INT, + ip_address VARCHAR(45), + error_message TEXT, + -- 时间 + checked_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_proxy_health_checks_proxy FOREIGN KEY (proxy_uuid) + REFERENCES proxies(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_proxy_health_checks_proxy_uuid ON proxy_health_checks(proxy_uuid); +CREATE INDEX idx_proxy_health_checks_checked_at ON proxy_health_checks(checked_at); +CREATE INDEX idx_proxy_health_checks_status ON proxy_health_checks(status); + diff --git a/server/migrations/20250120000010_create_environments.sql b/server/migrations/20250120000010_create_environments.sql new file mode 100644 index 00000000..c8e45ebc --- /dev/null +++ b/server/migrations/20250120000010_create_environments.sql @@ -0,0 +1,48 @@ +-- 创建 environments 表 +-- 环境基础信息表 + +CREATE TABLE IF NOT EXISTS environments ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 基础信息 + name VARCHAR(255) NOT NULL, + description TEXT, + icon VARCHAR(50) DEFAULT 'chrome', + icon_color VARCHAR(50) DEFAULT 'text-gray-500', + -- 状态: ready, running, error + status VARCHAR(50) NOT NULL DEFAULT 'ready', + -- 【关联】分组 + group_uuid UUID, + -- 【关联】代理(环境直接使用的代理,优先级高于分组默认代理) + proxy_uuid UUID, + -- 摘要信息(用于列表显示) + system_info VARCHAR(100), + kernel_info VARCHAR(100), + fingerprint_summary VARCHAR(255), + -- 时间 + last_opened_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_environments_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_environments_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid), + CONSTRAINT fk_environments_group FOREIGN KEY (group_uuid) REFERENCES groups(uuid) ON DELETE SET NULL, + CONSTRAINT fk_environments_proxy FOREIGN KEY (proxy_uuid) REFERENCES proxies(uuid) ON DELETE SET NULL +); + +-- 创建索引 +CREATE INDEX idx_environments_user_uuid ON environments(user_uuid); +CREATE INDEX idx_environments_team_uuid ON environments(team_uuid); +CREATE INDEX idx_environments_group_uuid ON environments(group_uuid); +CREATE INDEX idx_environments_proxy_uuid ON environments(proxy_uuid); +CREATE INDEX idx_environments_status ON environments(status); +CREATE INDEX idx_environments_deleted_at ON environments(deleted_at); +CREATE INDEX idx_environments_name ON environments(name); + +-- 创建更新时间触发器 +CREATE TRIGGER update_environments_updated_at BEFORE UPDATE ON environments + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000011_create_environment_configs.sql b/server/migrations/20250120000011_create_environment_configs.sql new file mode 100644 index 00000000..a2aca75b --- /dev/null +++ b/server/migrations/20250120000011_create_environment_configs.sql @@ -0,0 +1,33 @@ +-- 创建 environment_configs 表 +-- 环境完整配置表(存储 WindowConfig,与 environments 1:1) + +CREATE TABLE IF NOT EXISTS environment_configs ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL UNIQUE, + -- WindowInfo + window_info JSONB NOT NULL DEFAULT '{}', + -- BasicSettings + basic_settings JSONB NOT NULL DEFAULT '{}', + -- AdvancedFingerprintSettings + fingerprint_settings JSONB NOT NULL DEFAULT '{}', + -- DeviceSettings + device_settings JSONB NOT NULL DEFAULT '{}', + -- PreferenceSettings + preference_settings JSONB NOT NULL DEFAULT '{}', + -- ProjectMetadata + project_metadata JSONB NOT NULL DEFAULT '{}', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_configs_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_env_configs_env_uuid ON environment_configs(environment_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_environment_configs_updated_at BEFORE UPDATE ON environment_configs + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000012_create_environment_tags.sql b/server/migrations/20250120000012_create_environment_tags.sql new file mode 100644 index 00000000..e92b6714 --- /dev/null +++ b/server/migrations/20250120000012_create_environment_tags.sql @@ -0,0 +1,21 @@ +-- 创建 environment_tags 表 +-- 环境-标签关联表(多对多) + +CREATE TABLE IF NOT EXISTS environment_tags ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL, + tag_uuid UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_tags_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE, + CONSTRAINT fk_env_tags_tag FOREIGN KEY (tag_uuid) + REFERENCES tags(uuid) ON DELETE CASCADE, + -- 唯一约束:同一环境不能重复添加同一标签 + CONSTRAINT uk_env_tags UNIQUE (environment_uuid, tag_uuid) +); + +-- 创建索引 +CREATE INDEX idx_env_tags_env_uuid ON environment_tags(environment_uuid); +CREATE INDEX idx_env_tags_tag_uuid ON environment_tags(tag_uuid); + diff --git a/server/migrations/20250120000013_create_environment_urls.sql b/server/migrations/20250120000013_create_environment_urls.sql new file mode 100644 index 00000000..0000fd61 --- /dev/null +++ b/server/migrations/20250120000013_create_environment_urls.sql @@ -0,0 +1,18 @@ +-- 创建 environment_urls 表 +-- 环境预设 URL 表(环境可有多个预设 URL) + +CREATE TABLE IF NOT EXISTS environment_urls ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL, + url VARCHAR(2048) NOT NULL, + title VARCHAR(255), + sort_order INT DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_urls_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_env_urls_env_uuid ON environment_urls(environment_uuid); + diff --git a/server/migrations/20250120000014_create_environment_cookies.sql b/server/migrations/20250120000014_create_environment_cookies.sql new file mode 100644 index 00000000..54754acf --- /dev/null +++ b/server/migrations/20250120000014_create_environment_cookies.sql @@ -0,0 +1,24 @@ +-- 创建 environment_cookies 表 +-- 环境 Cookie 表(环境可导入多个 Cookie) + +CREATE TABLE IF NOT EXISTS environment_cookies ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL, + domain VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + value TEXT NOT NULL, + path VARCHAR(255) DEFAULT '/', + expires_at TIMESTAMP WITH TIME ZONE, + http_only BOOLEAN DEFAULT FALSE, + secure BOOLEAN DEFAULT FALSE, + same_site VARCHAR(20) DEFAULT 'Lax', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_cookies_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_env_cookies_env_uuid ON environment_cookies(environment_uuid); +CREATE INDEX idx_env_cookies_domain ON environment_cookies(domain); + diff --git a/server/migrations/20250120000015_create_templates.sql b/server/migrations/20250120000015_create_templates.sql new file mode 100644 index 00000000..b052a46d --- /dev/null +++ b/server/migrations/20250120000015_create_templates.sql @@ -0,0 +1,38 @@ +-- 创建 templates 表 +-- 环境配置模板表 + +CREATE TABLE IF NOT EXISTS templates ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + name VARCHAR(255) NOT NULL, + description TEXT, + -- 是否公开(团队内所有人可用) + is_public BOOLEAN DEFAULT FALSE, + -- 摘要 + system_info VARCHAR(100), + kernel_info VARCHAR(100), + -- 完整配置(JSON 存储 WindowConfig 结构) + config_json JSONB NOT NULL DEFAULT '{}', + -- 使用统计 + usage_count INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_templates_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_templates_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_templates_user_uuid ON templates(user_uuid); +CREATE INDEX idx_templates_team_uuid ON templates(team_uuid); +CREATE INDEX idx_templates_is_public ON templates(is_public); +CREATE INDEX idx_templates_deleted_at ON templates(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_templates_updated_at BEFORE UPDATE ON templates + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000016_create_platform_accounts.sql b/server/migrations/20250120000016_create_platform_accounts.sql new file mode 100644 index 00000000..f98ea803 --- /dev/null +++ b/server/migrations/20250120000016_create_platform_accounts.sql @@ -0,0 +1,40 @@ +-- 创建 platform_accounts 表 +-- 平台账号表 + +CREATE TABLE IF NOT EXISTS platform_accounts ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 平台信息 + platform_url VARCHAR(512) NOT NULL, + platform_name VARCHAR(100), + -- 账号信息 + account VARCHAR(255) NOT NULL, + password_encrypted TEXT, + -- 状态: active, inactive, expired + status VARCHAR(50) NOT NULL DEFAULT 'active', + remark TEXT, + -- 统计 + usage_count INT DEFAULT 0, + last_used_at TIMESTAMP WITH TIME ZONE, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_platform_accounts_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_platform_accounts_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_platform_accounts_user_uuid ON platform_accounts(user_uuid); +CREATE INDEX idx_platform_accounts_team_uuid ON platform_accounts(team_uuid); +CREATE INDEX idx_platform_accounts_platform_name ON platform_accounts(platform_name); +CREATE INDEX idx_platform_accounts_status ON platform_accounts(status); +CREATE INDEX idx_platform_accounts_deleted_at ON platform_accounts(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_platform_accounts_updated_at BEFORE UPDATE ON platform_accounts + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000017_create_environment_accounts.sql b/server/migrations/20250120000017_create_environment_accounts.sql new file mode 100644 index 00000000..d22007fa --- /dev/null +++ b/server/migrations/20250120000017_create_environment_accounts.sql @@ -0,0 +1,22 @@ +-- 创建 environment_accounts 表 +-- 环境-账号关联表(多对多) + +CREATE TABLE IF NOT EXISTS environment_accounts ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL, + account_uuid UUID NOT NULL, + sort_order INT DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_accounts_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE, + CONSTRAINT fk_env_accounts_account FOREIGN KEY (account_uuid) + REFERENCES platform_accounts(uuid) ON DELETE CASCADE, + -- 唯一约束:同一环境不能重复关联同一账号 + CONSTRAINT uk_env_accounts UNIQUE (environment_uuid, account_uuid) +); + +-- 创建索引 +CREATE INDEX idx_env_accounts_env_uuid ON environment_accounts(environment_uuid); +CREATE INDEX idx_env_accounts_account_uuid ON environment_accounts(account_uuid); + diff --git a/server/migrations/20250120000018_create_extensions.sql b/server/migrations/20250120000018_create_extensions.sql new file mode 100644 index 00000000..b2d47f9c --- /dev/null +++ b/server/migrations/20250120000018_create_extensions.sql @@ -0,0 +1,48 @@ +-- 创建 extensions 表 +-- 浏览器扩展表 + +CREATE TABLE IF NOT EXISTS extensions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 扩展 ID(Chrome/Firefox 商店 ID) + extension_id VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + description TEXT, + -- 版本信息 + version VARCHAR(50) NOT NULL, + -- 分类: productivity, privacy, automation, social, development, other + category VARCHAR(50) NOT NULL DEFAULT 'other', + -- 浏览器: chrome, firefox, edge, all + browser VARCHAR(50) NOT NULL DEFAULT 'all', + -- 开发者信息 + developer VARCHAR(255), + homepage VARCHAR(512), + icon_url VARCHAR(512), + -- 下载信息 + download_url VARCHAR(512), + file_size BIGINT, + -- 统计 + downloads_count BIGINT DEFAULT 0, + rating DECIMAL(3, 2), + -- 权限 + permissions JSONB DEFAULT '[]', + -- 状态: active, deprecated, removed + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 更新日志 + changelog JSONB DEFAULT '[]', + -- 时间 + published_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_extensions_extension_id ON extensions(extension_id); +CREATE INDEX idx_extensions_category ON extensions(category); +CREATE INDEX idx_extensions_browser ON extensions(browser); +CREATE INDEX idx_extensions_status ON extensions(status); + +-- 创建更新时间触发器 +CREATE TRIGGER update_extensions_updated_at BEFORE UPDATE ON extensions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000019_create_user_extensions.sql b/server/migrations/20250120000019_create_user_extensions.sql new file mode 100644 index 00000000..d3515192 --- /dev/null +++ b/server/migrations/20250120000019_create_user_extensions.sql @@ -0,0 +1,28 @@ +-- 创建 user_extensions 表 +-- 用户安装的扩展(用户级别) + +CREATE TABLE IF NOT EXISTS user_extensions ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL, + extension_id VARCHAR(255) NOT NULL, + installed_version VARCHAR(50) NOT NULL, + -- 状态: installed, disabled + status VARCHAR(50) NOT NULL DEFAULT 'installed', + -- 时间 + installed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_user_extensions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT fk_user_extensions_ext FOREIGN KEY (extension_id) REFERENCES extensions(extension_id) ON DELETE CASCADE, + -- 唯一约束 + CONSTRAINT uk_user_extensions UNIQUE (user_uuid, extension_id) +); + +-- 创建索引 +CREATE INDEX idx_user_extensions_user_uuid ON user_extensions(user_uuid); +CREATE INDEX idx_user_extensions_extension_id ON user_extensions(extension_id); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_extensions_updated_at BEFORE UPDATE ON user_extensions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000020_create_team_extensions.sql b/server/migrations/20250120000020_create_team_extensions.sql new file mode 100644 index 00000000..80ff16f7 --- /dev/null +++ b/server/migrations/20250120000020_create_team_extensions.sql @@ -0,0 +1,31 @@ +-- 创建 team_extensions 表 +-- 团队安装的扩展(团队级别,所有团队成员可用) + +CREATE TABLE IF NOT EXISTS team_extensions ( + id SERIAL PRIMARY KEY, + team_uuid UUID NOT NULL, + extension_id VARCHAR(255) NOT NULL, + installed_version VARCHAR(50) NOT NULL, + -- 安装者 + installed_by UUID NOT NULL, + -- 状态: installed, disabled + status VARCHAR(50) NOT NULL DEFAULT 'installed', + -- 时间 + installed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_team_extensions_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_team_extensions_ext FOREIGN KEY (extension_id) REFERENCES extensions(extension_id) ON DELETE CASCADE, + CONSTRAINT fk_team_extensions_user FOREIGN KEY (installed_by) REFERENCES users(uuid), + -- 唯一约束 + CONSTRAINT uk_team_extensions UNIQUE (team_uuid, extension_id) +); + +-- 创建索引 +CREATE INDEX idx_team_extensions_team_uuid ON team_extensions(team_uuid); +CREATE INDEX idx_team_extensions_extension_id ON team_extensions(extension_id); + +-- 创建更新时间触发器 +CREATE TRIGGER update_team_extensions_updated_at BEFORE UPDATE ON team_extensions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000021_create_group_extensions.sql b/server/migrations/20250120000021_create_group_extensions.sql new file mode 100644 index 00000000..73f9a7c7 --- /dev/null +++ b/server/migrations/20250120000021_create_group_extensions.sql @@ -0,0 +1,31 @@ +-- 创建 group_extensions 表 +-- 分组安装的扩展(分组级别,该分组下所有环境可用) + +CREATE TABLE IF NOT EXISTS group_extensions ( + id SERIAL PRIMARY KEY, + group_uuid UUID NOT NULL, + extension_id VARCHAR(255) NOT NULL, + installed_version VARCHAR(50) NOT NULL, + -- 安装者 + installed_by UUID NOT NULL, + -- 状态: installed, disabled + status VARCHAR(50) NOT NULL DEFAULT 'installed', + -- 时间 + installed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_group_extensions_group FOREIGN KEY (group_uuid) REFERENCES groups(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_extensions_ext FOREIGN KEY (extension_id) REFERENCES extensions(extension_id) ON DELETE CASCADE, + CONSTRAINT fk_group_extensions_user FOREIGN KEY (installed_by) REFERENCES users(uuid), + -- 唯一约束 + CONSTRAINT uk_group_extensions UNIQUE (group_uuid, extension_id) +); + +-- 创建索引 +CREATE INDEX idx_group_extensions_group_uuid ON group_extensions(group_uuid); +CREATE INDEX idx_group_extensions_extension_id ON group_extensions(extension_id); + +-- 创建更新时间触发器 +CREATE TRIGGER update_group_extensions_updated_at BEFORE UPDATE ON group_extensions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000022_create_environment_extensions.sql b/server/migrations/20250120000022_create_environment_extensions.sql new file mode 100644 index 00000000..d1bcdc09 --- /dev/null +++ b/server/migrations/20250120000022_create_environment_extensions.sql @@ -0,0 +1,28 @@ +-- 创建 environment_extensions 表 +-- 环境安装的扩展(环境级别) + +CREATE TABLE IF NOT EXISTS environment_extensions ( + id SERIAL PRIMARY KEY, + environment_uuid UUID NOT NULL, + extension_id VARCHAR(255) NOT NULL, + installed_version VARCHAR(50) NOT NULL, + -- 状态: installed, disabled + status VARCHAR(50) NOT NULL DEFAULT 'installed', + -- 时间 + installed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_extensions_env FOREIGN KEY (environment_uuid) REFERENCES environments(uuid) ON DELETE CASCADE, + CONSTRAINT fk_env_extensions_ext FOREIGN KEY (extension_id) REFERENCES extensions(extension_id) ON DELETE CASCADE, + -- 唯一约束 + CONSTRAINT uk_env_extensions UNIQUE (environment_uuid, extension_id) +); + +-- 创建索引 +CREATE INDEX idx_env_extensions_env_uuid ON environment_extensions(environment_uuid); +CREATE INDEX idx_env_extensions_extension_id ON environment_extensions(extension_id); + +-- 创建更新时间触发器 +CREATE TRIGGER update_environment_extensions_updated_at BEFORE UPDATE ON environment_extensions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000023_create_plans.sql b/server/migrations/20250120000023_create_plans.sql new file mode 100644 index 00000000..1848d36d --- /dev/null +++ b/server/migrations/20250120000023_create_plans.sql @@ -0,0 +1,40 @@ +-- 创建 plans 表 +-- 订阅套餐表 + +CREATE TABLE IF NOT EXISTS plans ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 基础信息 + name VARCHAR(100) NOT NULL, + description TEXT, + -- 价格 + price_per_month DECIMAL(12, 2) NOT NULL DEFAULT 0, + price_per_year DECIMAL(12, 2) NOT NULL DEFAULT 0, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 折扣 + discount_monthly DECIMAL(5, 2) DEFAULT 0, + discount_yearly DECIMAL(5, 2) DEFAULT 0, + -- 配额限制 + max_environments INT NOT NULL DEFAULT 10, + max_team_members INT NOT NULL DEFAULT 5, + max_proxies INT NOT NULL DEFAULT 10, + max_rpa_tasks INT NOT NULL DEFAULT 5, + -- 是否推荐 + is_recommended BOOLEAN DEFAULT FALSE, + -- 排序 + sort_order INT DEFAULT 0, + -- 状态: active, deprecated + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_plans_status ON plans(status); +CREATE INDEX idx_plans_sort_order ON plans(sort_order); + +-- 创建更新时间触发器 +CREATE TRIGGER update_plans_updated_at BEFORE UPDATE ON plans + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000024_create_plan_features.sql b/server/migrations/20250120000024_create_plan_features.sql new file mode 100644 index 00000000..531cdd81 --- /dev/null +++ b/server/migrations/20250120000024_create_plan_features.sql @@ -0,0 +1,25 @@ +-- 创建 plan_features 表 +-- 套餐功能特性表 + +CREATE TABLE IF NOT EXISTS plan_features ( + id SERIAL PRIMARY KEY, + plan_uuid UUID NOT NULL, + -- 特性 + feature_key VARCHAR(100) NOT NULL, + feature_name VARCHAR(255) NOT NULL, + feature_value VARCHAR(255), + -- 是否包含 + is_included BOOLEAN DEFAULT TRUE, + -- 排序 + sort_order INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_plan_features_plan FOREIGN KEY (plan_uuid) REFERENCES plans(uuid) ON DELETE CASCADE, + -- 唯一约束 + CONSTRAINT uk_plan_features UNIQUE (plan_uuid, feature_key) +); + +-- 创建索引 +CREATE INDEX idx_plan_features_plan_uuid ON plan_features(plan_uuid); + diff --git a/server/migrations/20250120000025_create_subscriptions.sql b/server/migrations/20250120000025_create_subscriptions.sql new file mode 100644 index 00000000..b8a7c8ca --- /dev/null +++ b/server/migrations/20250120000025_create_subscriptions.sql @@ -0,0 +1,40 @@ +-- 创建 subscriptions 表 +-- 用户订阅表 + +CREATE TABLE IF NOT EXISTS subscriptions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + plan_uuid UUID NOT NULL, + -- 订阅周期: monthly, yearly + billing_period VARCHAR(20) NOT NULL DEFAULT 'monthly', + -- 价格快照 + price DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 时间 + started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + next_billing_date DATE, + -- 自动续费 + auto_renew BOOLEAN DEFAULT TRUE, + -- 状态: active, cancelled, expired, suspended + status VARCHAR(50) NOT NULL DEFAULT 'active', + cancelled_at TIMESTAMP WITH TIME ZONE, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_subscriptions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_subscriptions_plan FOREIGN KEY (plan_uuid) REFERENCES plans(uuid) +); + +-- 创建索引 +CREATE INDEX idx_subscriptions_user_uuid ON subscriptions(user_uuid); +CREATE INDEX idx_subscriptions_plan_uuid ON subscriptions(plan_uuid); +CREATE INDEX idx_subscriptions_status ON subscriptions(status); +CREATE INDEX idx_subscriptions_expires_at ON subscriptions(expires_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_subscriptions_updated_at BEFORE UPDATE ON subscriptions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000026_create_user_wallets.sql b/server/migrations/20250120000026_create_user_wallets.sql new file mode 100644 index 00000000..36c4f861 --- /dev/null +++ b/server/migrations/20250120000026_create_user_wallets.sql @@ -0,0 +1,27 @@ +-- 创建 user_wallets 表 +-- 用户钱包表 + +CREATE TABLE IF NOT EXISTS user_wallets ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL UNIQUE, + -- 余额 + balance DECIMAL(12, 2) NOT NULL DEFAULT 0, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 冻结金额 + frozen_amount DECIMAL(12, 2) NOT NULL DEFAULT 0, + -- 自动续费组合金额 + auto_renewal_combined DECIMAL(12, 2) NOT NULL DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_user_wallets_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_user_wallets_user_uuid ON user_wallets(user_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_wallets_updated_at BEFORE UPDATE ON user_wallets + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000027_create_wallet_transactions.sql b/server/migrations/20250120000027_create_wallet_transactions.sql new file mode 100644 index 00000000..b556ffc9 --- /dev/null +++ b/server/migrations/20250120000027_create_wallet_transactions.sql @@ -0,0 +1,33 @@ +-- 创建 wallet_transactions 表 +-- 钱包交易记录表 + +CREATE TABLE IF NOT EXISTS wallet_transactions ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 交易类型: recharge, consume, refund, reward + transaction_type VARCHAR(50) NOT NULL, + -- 金额(正数为收入,负数为支出) + amount DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 余额快照 + balance_before DECIMAL(12, 2) NOT NULL, + balance_after DECIMAL(12, 2) NOT NULL, + -- 描述 + description TEXT, + -- 关联订单 + order_uuid UUID, + -- 状态: pending, completed, failed + status VARCHAR(50) NOT NULL DEFAULT 'completed', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_wallet_transactions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_wallet_transactions_user_uuid ON wallet_transactions(user_uuid); +CREATE INDEX idx_wallet_transactions_type ON wallet_transactions(transaction_type); +CREATE INDEX idx_wallet_transactions_created_at ON wallet_transactions(created_at); +CREATE INDEX idx_wallet_transactions_order_uuid ON wallet_transactions(order_uuid); + diff --git a/server/migrations/20250120000028_create_invoices.sql b/server/migrations/20250120000028_create_invoices.sql new file mode 100644 index 00000000..84e7c706 --- /dev/null +++ b/server/migrations/20250120000028_create_invoices.sql @@ -0,0 +1,44 @@ +-- 创建 invoices 表 +-- 发票表 + +CREATE TABLE IF NOT EXISTS invoices ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 发票号 + invoice_number VARCHAR(100) NOT NULL UNIQUE, + -- 金额 + amount DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 关联订阅/订单 + subscription_uuid UUID, + order_uuid UUID, + -- 发票类型: subscription, addon, recharge + invoice_type VARCHAR(50) NOT NULL, + -- 状态: draft, issued, paid, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'draft', + -- 时间 + issued_at TIMESTAMP WITH TIME ZONE, + due_at TIMESTAMP WITH TIME ZONE, + paid_at TIMESTAMP WITH TIME ZONE, + -- 发票信息(PDF 链接等) + invoice_url VARCHAR(512), + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_invoices_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_invoices_subscription FOREIGN KEY (subscription_uuid) REFERENCES subscriptions(uuid) +); + +-- 创建索引 +CREATE INDEX idx_invoices_user_uuid ON invoices(user_uuid); +CREATE INDEX idx_invoices_invoice_number ON invoices(invoice_number); +CREATE INDEX idx_invoices_status ON invoices(status); +CREATE INDEX idx_invoices_subscription_uuid ON invoices(subscription_uuid); +CREATE INDEX idx_invoices_created_at ON invoices(created_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_invoices_updated_at BEFORE UPDATE ON invoices + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000029_create_user_quotas.sql b/server/migrations/20250120000029_create_user_quotas.sql new file mode 100644 index 00000000..283f6ea2 --- /dev/null +++ b/server/migrations/20250120000029_create_user_quotas.sql @@ -0,0 +1,31 @@ +-- 创建 user_quotas 表 +-- 用户配额表 + +CREATE TABLE IF NOT EXISTS user_quotas ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL UNIQUE, + -- 环境配额 + max_environments INT NOT NULL DEFAULT 10, + used_environments INT NOT NULL DEFAULT 0, + -- 团队成员配额 + max_team_members INT NOT NULL DEFAULT 5, + -- 代理配额 + max_proxies INT NOT NULL DEFAULT 10, + used_proxies INT NOT NULL DEFAULT 0, + -- RPA 任务配额 + max_rpa_tasks INT NOT NULL DEFAULT 5, + used_rpa_tasks INT NOT NULL DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_user_quotas_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_user_quotas_user_uuid ON user_quotas(user_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_quotas_updated_at BEFORE UPDATE ON user_quotas + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000030_create_coupons.sql b/server/migrations/20250120000030_create_coupons.sql new file mode 100644 index 00000000..6713f092 --- /dev/null +++ b/server/migrations/20250120000030_create_coupons.sql @@ -0,0 +1,42 @@ +-- 创建 coupons 表 +-- 优惠券表 + +CREATE TABLE IF NOT EXISTS coupons ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 优惠券码 + code VARCHAR(50) NOT NULL UNIQUE, + -- 优惠类型: percent, fixed + discount_type VARCHAR(20) NOT NULL, + -- 优惠值(百分比或固定金额) + discount_value DECIMAL(12, 2) NOT NULL, + -- 最低消费 + min_amount DECIMAL(12, 2) DEFAULT 0, + -- 最大折扣(百分比类型时有效) + max_discount DECIMAL(12, 2), + -- 使用限制 + max_uses INT, + used_count INT NOT NULL DEFAULT 0, + -- 每用户限制 + max_uses_per_user INT DEFAULT 1, + -- 有效期 + valid_from TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + valid_until TIMESTAMP WITH TIME ZONE, + -- 适用范围: all, subscription, addon + applicable_to VARCHAR(50) NOT NULL DEFAULT 'all', + -- 状态: active, inactive, expired + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_coupons_code ON coupons(code); +CREATE INDEX idx_coupons_status ON coupons(status); +CREATE INDEX idx_coupons_valid_until ON coupons(valid_until); + +-- 创建更新时间触发器 +CREATE TRIGGER update_coupons_updated_at BEFORE UPDATE ON coupons + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000031_create_coupon_usages.sql b/server/migrations/20250120000031_create_coupon_usages.sql new file mode 100644 index 00000000..1242fabd --- /dev/null +++ b/server/migrations/20250120000031_create_coupon_usages.sql @@ -0,0 +1,21 @@ +-- 创建 coupon_usages 表 +-- 优惠券使用记录表 + +CREATE TABLE IF NOT EXISTS coupon_usages ( + id SERIAL PRIMARY KEY, + coupon_uuid UUID NOT NULL, + user_uuid UUID NOT NULL, + order_uuid UUID, + -- 折扣金额 + discount_amount DECIMAL(12, 2) NOT NULL, + -- 使用时间 + used_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_coupon_usages_coupon FOREIGN KEY (coupon_uuid) REFERENCES coupons(uuid), + CONSTRAINT fk_coupon_usages_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_coupon_usages_coupon_uuid ON coupon_usages(coupon_uuid); +CREATE INDEX idx_coupon_usages_user_uuid ON coupon_usages(user_uuid); + diff --git a/server/migrations/20250120000032_create_payment_orders.sql b/server/migrations/20250120000032_create_payment_orders.sql new file mode 100644 index 00000000..70f66353 --- /dev/null +++ b/server/migrations/20250120000032_create_payment_orders.sql @@ -0,0 +1,50 @@ +-- 创建 payment_orders 表 +-- 支付订单表 + +CREATE TABLE IF NOT EXISTS payment_orders ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 订单号 + order_no VARCHAR(100) NOT NULL UNIQUE, + user_uuid UUID NOT NULL, + -- 订单类型: recharge, subscription, addon, refund + order_type VARCHAR(50) NOT NULL, + -- 金额 + amount DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 状态: pending, paid, failed, refunded, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'pending', + -- 支付渠道: alipay, wechatpay, stripe, paypal 等 + payment_channel VARCHAR(50), + -- 第三方订单号 + external_order_id VARCHAR(255), + -- 描述 + description TEXT, + -- 关联 + subscription_uuid UUID, + coupon_uuid UUID, + -- 折扣信息 + original_amount DECIMAL(12, 2), + discount_amount DECIMAL(12, 2) DEFAULT 0, + -- 时间 + paid_at TIMESTAMP WITH TIME ZONE, + refunded_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_payment_orders_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_payment_orders_subscription FOREIGN KEY (subscription_uuid) REFERENCES subscriptions(uuid), + CONSTRAINT fk_payment_orders_coupon FOREIGN KEY (coupon_uuid) REFERENCES coupons(uuid) +); + +-- 创建索引 +CREATE INDEX idx_payment_orders_user_uuid ON payment_orders(user_uuid); +CREATE INDEX idx_payment_orders_order_no ON payment_orders(order_no); +CREATE INDEX idx_payment_orders_status ON payment_orders(status); +CREATE INDEX idx_payment_orders_order_type ON payment_orders(order_type); +CREATE INDEX idx_payment_orders_created_at ON payment_orders(created_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_payment_orders_updated_at BEFORE UPDATE ON payment_orders + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000033_create_auto_renewal_services.sql b/server/migrations/20250120000033_create_auto_renewal_services.sql new file mode 100644 index 00000000..1f045ace --- /dev/null +++ b/server/migrations/20250120000033_create_auto_renewal_services.sql @@ -0,0 +1,36 @@ +-- 创建 auto_renewal_services 表 +-- 自动续费服务表 + +CREATE TABLE IF NOT EXISTS auto_renewal_services ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 服务类型: subscription, addon + service_type VARCHAR(50) NOT NULL, + -- 关联服务 ID + service_uuid UUID, + -- 服务名称 + service_name VARCHAR(255) NOT NULL, + -- 续费价格 + renewal_price DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + -- 下次扣费日期 + next_bill_date DATE NOT NULL, + -- 状态: active, paused, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_auto_renewal_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_auto_renewal_services_user_uuid ON auto_renewal_services(user_uuid); +CREATE INDEX idx_auto_renewal_services_status ON auto_renewal_services(status); +CREATE INDEX idx_auto_renewal_services_next_bill_date ON auto_renewal_services(next_bill_date); + +-- 创建更新时间触发器 +CREATE TRIGGER update_auto_renewal_services_updated_at BEFORE UPDATE ON auto_renewal_services + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000034_create_rpa_tasks.sql b/server/migrations/20250120000034_create_rpa_tasks.sql new file mode 100644 index 00000000..bb37bc23 --- /dev/null +++ b/server/migrations/20250120000034_create_rpa_tasks.sql @@ -0,0 +1,59 @@ +-- 创建 rpa_tasks 表 +-- RPA 任务表 + +CREATE TABLE IF NOT EXISTS rpa_tasks ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 基础信息 + name VARCHAR(255) NOT NULL, + description TEXT, + tags JSONB DEFAULT '[]', + -- 触发器: manual, scheduled, event + trigger_type VARCHAR(50) NOT NULL DEFAULT 'manual', + -- 调度: hourly, daily, weekly, custom + schedule VARCHAR(50), + cron_expression VARCHAR(100), + -- 运行模式: sequential, parallel + run_mode VARCHAR(50) NOT NULL DEFAULT 'sequential', + -- 重试设置 + retry_count INT DEFAULT 0, + retry_interval INT DEFAULT 5, + -- 超时(秒) + timeout INT DEFAULT 300, + -- 并发数 + concurrency INT DEFAULT 1, + -- 错误时停止 + stop_on_error BOOLEAN DEFAULT TRUE, + -- 通知设置 + notify_on_complete BOOLEAN DEFAULT FALSE, + notify_on_error BOOLEAN DEFAULT TRUE, + -- 状态: idle, running, completed, failed, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'idle', + -- 统计 + run_count INT DEFAULT 0, + success_count INT DEFAULT 0, + last_run_at TIMESTAMP WITH TIME ZONE, + next_run_at TIMESTAMP WITH TIME ZONE, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_rpa_tasks_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_rpa_tasks_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_rpa_tasks_user_uuid ON rpa_tasks(user_uuid); +CREATE INDEX idx_rpa_tasks_team_uuid ON rpa_tasks(team_uuid); +CREATE INDEX idx_rpa_tasks_trigger_type ON rpa_tasks(trigger_type); +CREATE INDEX idx_rpa_tasks_status ON rpa_tasks(status); +CREATE INDEX idx_rpa_tasks_next_run_at ON rpa_tasks(next_run_at); +CREATE INDEX idx_rpa_tasks_deleted_at ON rpa_tasks(deleted_at); + +-- 创建更新时间触发器 +CREATE TRIGGER update_rpa_tasks_updated_at BEFORE UPDATE ON rpa_tasks + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000035_create_rpa_task_steps.sql b/server/migrations/20250120000035_create_rpa_task_steps.sql new file mode 100644 index 00000000..c0deaaff --- /dev/null +++ b/server/migrations/20250120000035_create_rpa_task_steps.sql @@ -0,0 +1,39 @@ +-- 创建 rpa_task_steps 表 +-- RPA 任务步骤表 + +CREATE TABLE IF NOT EXISTS rpa_task_steps ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + task_uuid UUID NOT NULL, + -- 步骤类型: navigate, click, input, wait, screenshot, script, condition, loop, scroll, keyboard, download, upload + step_type VARCHAR(50) NOT NULL, + -- 步骤名称 + name VARCHAR(255) NOT NULL, + -- 步骤配置(JSON) + config JSONB NOT NULL DEFAULT '{}', + -- 是否启用 + enabled BOOLEAN DEFAULT TRUE, + -- 画布位置 + position_x INT DEFAULT 0, + position_y INT DEFAULT 0, + -- 排序 + sort_order INT DEFAULT 0, + -- 连接到的下一个步骤 + next_step_uuid UUID, + -- 条件分支(条件类型步骤) + branch_config JSONB, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_rpa_task_steps_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_rpa_task_steps_task_uuid ON rpa_task_steps(task_uuid); +CREATE INDEX idx_rpa_task_steps_sort_order ON rpa_task_steps(sort_order); + +-- 创建更新时间触发器 +CREATE TRIGGER update_rpa_task_steps_updated_at BEFORE UPDATE ON rpa_task_steps + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000036_create_rpa_task_environments.sql b/server/migrations/20250120000036_create_rpa_task_environments.sql new file mode 100644 index 00000000..e68d5a6c --- /dev/null +++ b/server/migrations/20250120000036_create_rpa_task_environments.sql @@ -0,0 +1,20 @@ +-- 创建 rpa_task_environments 表 +-- RPA 任务-环境关联表(多对多) + +CREATE TABLE IF NOT EXISTS rpa_task_environments ( + id SERIAL PRIMARY KEY, + task_uuid UUID NOT NULL, + environment_uuid UUID NOT NULL, + sort_order INT DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_rpa_task_env_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE, + CONSTRAINT fk_rpa_task_env_env FOREIGN KEY (environment_uuid) REFERENCES environments(uuid) ON DELETE CASCADE, + -- 唯一约束 + CONSTRAINT uk_rpa_task_environments UNIQUE (task_uuid, environment_uuid) +); + +-- 创建索引 +CREATE INDEX idx_rpa_task_env_task_uuid ON rpa_task_environments(task_uuid); +CREATE INDEX idx_rpa_task_env_env_uuid ON rpa_task_environments(environment_uuid); + diff --git a/server/migrations/20250120000037_create_rpa_task_runs.sql b/server/migrations/20250120000037_create_rpa_task_runs.sql new file mode 100644 index 00000000..664da2ef --- /dev/null +++ b/server/migrations/20250120000037_create_rpa_task_runs.sql @@ -0,0 +1,32 @@ +-- 创建 rpa_task_runs 表 +-- RPA 任务执行记录表 + +CREATE TABLE IF NOT EXISTS rpa_task_runs ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + task_uuid UUID NOT NULL, + -- 状态: running, completed, failed, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'running', + -- 步骤统计 + total_steps INT NOT NULL DEFAULT 0, + completed_steps INT NOT NULL DEFAULT 0, + failed_steps INT NOT NULL DEFAULT 0, + -- 执行时间 + started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at TIMESTAMP WITH TIME ZONE, + duration_ms BIGINT, + -- 结果摘要 + result_summary TEXT, + -- 错误信息 + error_message TEXT, + -- 执行日志(JSON 数组) + logs JSONB DEFAULT '[]', + -- 约束 + CONSTRAINT fk_rpa_task_runs_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_rpa_task_runs_task_uuid ON rpa_task_runs(task_uuid); +CREATE INDEX idx_rpa_task_runs_status ON rpa_task_runs(status); +CREATE INDEX idx_rpa_task_runs_started_at ON rpa_task_runs(started_at); + diff --git a/server/migrations/20250120000038_create_referral_link_tiers.sql b/server/migrations/20250120000038_create_referral_link_tiers.sql new file mode 100644 index 00000000..7da2d830 --- /dev/null +++ b/server/migrations/20250120000038_create_referral_link_tiers.sql @@ -0,0 +1,31 @@ +-- 创建 referral_link_tiers 表 +-- 推广链接层级配置表 + +CREATE TABLE IF NOT EXISTS referral_link_tiers ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 层级名称 + name VARCHAR(100) NOT NULL, + -- 解锁条件(邀请人数) + unlock_threshold INT NOT NULL DEFAULT 0, + -- 奖励比例(百分比) + reward_rate DECIMAL(5, 2) NOT NULL DEFAULT 0, + -- 被邀请人折扣比例(百分比) + discount_rate DECIMAL(5, 2) NOT NULL DEFAULT 0, + -- 描述 + description TEXT, + -- 排序 + sort_order INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_referral_link_tiers_unlock ON referral_link_tiers(unlock_threshold); +CREATE INDEX idx_referral_link_tiers_sort ON referral_link_tiers(sort_order); + +-- 创建更新时间触发器 +CREATE TRIGGER update_referral_link_tiers_updated_at BEFORE UPDATE ON referral_link_tiers + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000039_create_referral_links.sql b/server/migrations/20250120000039_create_referral_links.sql new file mode 100644 index 00000000..0366672c --- /dev/null +++ b/server/migrations/20250120000039_create_referral_links.sql @@ -0,0 +1,42 @@ +-- 创建 referral_links 表 +-- 用户推广链接表 + +CREATE TABLE IF NOT EXISTS referral_links ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 链接码 + code VARCHAR(50) NOT NULL UNIQUE, + -- 完整 URL + url VARCHAR(512), + -- 当前层级 + tier_uuid UUID, + -- 是否解锁 + unlocked BOOLEAN DEFAULT TRUE, + -- 是否当前使用 + is_current BOOLEAN DEFAULT FALSE, + -- 奖励比例(冗余,便于查询) + reward_rate DECIMAL(5, 2) NOT NULL DEFAULT 0, + discount_rate DECIMAL(5, 2) NOT NULL DEFAULT 0, + -- 统计 + registered_users INT DEFAULT 0, + paid_users INT DEFAULT 0, + total_consumption DECIMAL(12, 2) DEFAULT 0, + last_30_days_consumption DECIMAL(12, 2) DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_referral_links_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT fk_referral_links_tier FOREIGN KEY (tier_uuid) REFERENCES referral_link_tiers(uuid) +); + +-- 创建索引 +CREATE INDEX idx_referral_links_user_uuid ON referral_links(user_uuid); +CREATE INDEX idx_referral_links_code ON referral_links(code); +CREATE INDEX idx_referral_links_is_current ON referral_links(is_current); + +-- 创建更新时间触发器 +CREATE TRIGGER update_referral_links_updated_at BEFORE UPDATE ON referral_links + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000040_create_user_referrals.sql b/server/migrations/20250120000040_create_user_referrals.sql new file mode 100644 index 00000000..a52cdfb1 --- /dev/null +++ b/server/migrations/20250120000040_create_user_referrals.sql @@ -0,0 +1,32 @@ +-- 创建 user_referrals 表 +-- 用户邀请关系表 + +CREATE TABLE IF NOT EXISTS user_referrals ( + id SERIAL PRIMARY KEY, + -- 邀请者 + inviter_uuid UUID NOT NULL, + -- 被邀请者 + invitee_uuid UUID NOT NULL UNIQUE, + -- 通过哪个推广链接 + link_uuid UUID, + -- 状态: registered, activated, paid + status VARCHAR(50) NOT NULL DEFAULT 'registered', + -- 消费统计 + total_consumption DECIMAL(12, 2) DEFAULT 0, + last_30_days_consumption DECIMAL(12, 2) DEFAULT 0, + -- 时间 + registered_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + activated_at TIMESTAMP WITH TIME ZONE, + first_paid_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_user_referrals_inviter FOREIGN KEY (inviter_uuid) REFERENCES users(uuid), + CONSTRAINT fk_user_referrals_invitee FOREIGN KEY (invitee_uuid) REFERENCES users(uuid), + CONSTRAINT fk_user_referrals_link FOREIGN KEY (link_uuid) REFERENCES referral_links(uuid) +); + +-- 创建索引 +CREATE INDEX idx_user_referrals_inviter_uuid ON user_referrals(inviter_uuid); +CREATE INDEX idx_user_referrals_invitee_uuid ON user_referrals(invitee_uuid); +CREATE INDEX idx_user_referrals_status ON user_referrals(status); +CREATE INDEX idx_user_referrals_link_uuid ON user_referrals(link_uuid); + diff --git a/server/migrations/20250120000041_create_referral_rewards.sql b/server/migrations/20250120000041_create_referral_rewards.sql new file mode 100644 index 00000000..6d7f05dc --- /dev/null +++ b/server/migrations/20250120000041_create_referral_rewards.sql @@ -0,0 +1,33 @@ +-- 创建 referral_rewards 表 +-- 推荐奖励记录表 + +CREATE TABLE IF NOT EXISTS referral_rewards ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 奖励类型: registration, first_pay, consumption + reward_type VARCHAR(50) NOT NULL, + -- 积分 + points INT NOT NULL, + -- 描述 + description TEXT, + -- 关联被邀请用户 + referred_user_uuid UUID, + -- 关联推广链接 + link_uuid UUID, + -- 状态: pending, approved, rejected + status VARCHAR(50) NOT NULL DEFAULT 'pending', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_referral_rewards_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_referral_rewards_referred FOREIGN KEY (referred_user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_referral_rewards_link FOREIGN KEY (link_uuid) REFERENCES referral_links(uuid) +); + +-- 创建索引 +CREATE INDEX idx_referral_rewards_user_uuid ON referral_rewards(user_uuid); +CREATE INDEX idx_referral_rewards_reward_type ON referral_rewards(reward_type); +CREATE INDEX idx_referral_rewards_status ON referral_rewards(status); +CREATE INDEX idx_referral_rewards_created_at ON referral_rewards(created_at); + diff --git a/server/migrations/20250120000042_create_user_referral_points.sql b/server/migrations/20250120000042_create_user_referral_points.sql new file mode 100644 index 00000000..71476167 --- /dev/null +++ b/server/migrations/20250120000042_create_user_referral_points.sql @@ -0,0 +1,28 @@ +-- 创建 user_referral_points 表 +-- 用户推荐积分表 + +CREATE TABLE IF NOT EXISTS user_referral_points ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL UNIQUE, + -- 总积分 + total_points INT NOT NULL DEFAULT 0, + -- 可用积分 + available_points INT NOT NULL DEFAULT 0, + -- 已使用积分 + used_points INT NOT NULL DEFAULT 0, + -- 待审核积分 + pending_points INT NOT NULL DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_user_referral_points_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_user_referral_points_user_uuid ON user_referral_points(user_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_referral_points_updated_at BEFORE UPDATE ON user_referral_points + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000043_create_redeem_options.sql b/server/migrations/20250120000043_create_redeem_options.sql new file mode 100644 index 00000000..e1293e43 --- /dev/null +++ b/server/migrations/20250120000043_create_redeem_options.sql @@ -0,0 +1,36 @@ +-- 创建 redeem_options 表 +-- 积分兑换选项表 + +CREATE TABLE IF NOT EXISTS redeem_options ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + -- 兑换类型: wallet, coupon, gift + redeem_type VARCHAR(50) NOT NULL, + -- 名称 + name VARCHAR(255) NOT NULL, + -- 描述 + description TEXT, + -- 所需积分 + points_required INT NOT NULL, + -- 兑换价值 + value DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) DEFAULT 'USD', + -- 兑换比例(每 N 积分 = 1 单位价值) + exchange_rate INT NOT NULL DEFAULT 100, + -- 状态: active, inactive + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 排序 + sort_order INT DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_redeem_options_redeem_type ON redeem_options(redeem_type); +CREATE INDEX idx_redeem_options_status ON redeem_options(status); + +-- 创建更新时间触发器 +CREATE TRIGGER update_redeem_options_updated_at BEFORE UPDATE ON redeem_options + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000044_create_redeem_records.sql b/server/migrations/20250120000044_create_redeem_records.sql new file mode 100644 index 00000000..72eb103a --- /dev/null +++ b/server/migrations/20250120000044_create_redeem_records.sql @@ -0,0 +1,29 @@ +-- 创建 redeem_records 表 +-- 积分兑换记录表 + +CREATE TABLE IF NOT EXISTS redeem_records ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + option_uuid UUID NOT NULL, + -- 兑换积分 + points_used INT NOT NULL, + -- 兑换价值 + value DECIMAL(12, 2) NOT NULL, + currency VARCHAR(10) DEFAULT 'USD', + -- 状态: pending, completed, failed + status VARCHAR(50) NOT NULL DEFAULT 'pending', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_redeem_records_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_redeem_records_option FOREIGN KEY (option_uuid) REFERENCES redeem_options(uuid) +); + +-- 创建索引 +CREATE INDEX idx_redeem_records_user_uuid ON redeem_records(user_uuid); +CREATE INDEX idx_redeem_records_option_uuid ON redeem_records(option_uuid); +CREATE INDEX idx_redeem_records_status ON redeem_records(status); +CREATE INDEX idx_redeem_records_created_at ON redeem_records(created_at); + diff --git a/server/migrations/20250120000045_create_audit_logs.sql b/server/migrations/20250120000045_create_audit_logs.sql new file mode 100644 index 00000000..ecb8a4dd --- /dev/null +++ b/server/migrations/20250120000045_create_audit_logs.sql @@ -0,0 +1,37 @@ +-- 创建 audit_logs 表 +-- 审计日志表 + +CREATE TABLE IF NOT EXISTS audit_logs ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + team_uuid UUID, + -- 操作类型: login, logout, password_change, create, update, delete, batch_delete, start, stop, import, export, invite, role_change, member_remove, settings_update + action VARCHAR(50) NOT NULL, + -- 目标类型: environment, group, tag, proxy, account, team, settings, system + target_type VARCHAR(50) NOT NULL, + -- 目标 ID + target_uuid UUID, + target_name VARCHAR(255), + -- 详情 + details TEXT, + -- 变更内容(JSON) + changes JSONB, + -- 请求信息 + ip_address VARCHAR(45), + user_agent TEXT, + request_id VARCHAR(100), + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_audit_logs_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_audit_logs_user_uuid ON audit_logs(user_uuid); +CREATE INDEX idx_audit_logs_team_uuid ON audit_logs(team_uuid); +CREATE INDEX idx_audit_logs_action ON audit_logs(action); +CREATE INDEX idx_audit_logs_target_type ON audit_logs(target_type); +CREATE INDEX idx_audit_logs_target_uuid ON audit_logs(target_uuid); +CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); + diff --git a/server/migrations/20250120000046_create_api_keys.sql b/server/migrations/20250120000046_create_api_keys.sql new file mode 100644 index 00000000..7e7c8402 --- /dev/null +++ b/server/migrations/20250120000046_create_api_keys.sql @@ -0,0 +1,43 @@ +-- 创建 api_keys 表 +-- API 密钥表 + +CREATE TABLE IF NOT EXISTS api_keys ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + -- 密钥信息 + name VARCHAR(255) NOT NULL, + key_hash VARCHAR(255) NOT NULL, + key_prefix VARCHAR(20) NOT NULL, + -- 权限: read, write, delete, admin + permissions JSONB NOT NULL DEFAULT '["read"]', + -- 限流 + rate_limit INT DEFAULT 1000, + daily_limit INT DEFAULT 10000, + -- IP 白名单(JSON 数组) + ip_whitelist JSONB DEFAULT '[]', + -- 过期时间 + expires_at TIMESTAMP WITH TIME ZONE, + -- 使用统计 + usage_count BIGINT DEFAULT 0, + daily_usage INT DEFAULT 0, + last_used_at TIMESTAMP WITH TIME ZONE, + -- 状态: active, revoked + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_api_keys_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_api_keys_user_uuid ON api_keys(user_uuid); +CREATE INDEX idx_api_keys_key_hash ON api_keys(key_hash); +CREATE INDEX idx_api_keys_key_prefix ON api_keys(key_prefix); +CREATE INDEX idx_api_keys_status ON api_keys(status); + +-- 创建更新时间触发器 +CREATE TRIGGER update_api_keys_updated_at BEFORE UPDATE ON api_keys + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000047_create_user_preferences.sql b/server/migrations/20250120000047_create_user_preferences.sql new file mode 100644 index 00000000..97294b70 --- /dev/null +++ b/server/migrations/20250120000047_create_user_preferences.sql @@ -0,0 +1,26 @@ +-- 创建 user_preferences 表 +-- 用户偏好设置表(仅云同步设置) + +CREATE TABLE IF NOT EXISTS user_preferences ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL UNIQUE, + -- 主题: light, dark, system + theme VARCHAR(50) NOT NULL DEFAULT 'system', + -- 语言 + language VARCHAR(20) NOT NULL DEFAULT 'zh-CN', + -- 通知开关 + notifications_enabled BOOLEAN NOT NULL DEFAULT TRUE, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_user_preferences_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_user_preferences_user_uuid ON user_preferences(user_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_preferences_updated_at BEFORE UPDATE ON user_preferences + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000048_create_system_configs.sql b/server/migrations/20250120000048_create_system_configs.sql new file mode 100644 index 00000000..0e5078a0 --- /dev/null +++ b/server/migrations/20250120000048_create_system_configs.sql @@ -0,0 +1,23 @@ +-- 创建 system_configs 表 +-- 系统配置表(全局配置) + +CREATE TABLE IF NOT EXISTS system_configs ( + id SERIAL PRIMARY KEY, + -- 配置键 + config_key VARCHAR(255) NOT NULL UNIQUE, + -- 配置值(JSON) + config_value JSONB NOT NULL DEFAULT '{}', + -- 描述 + description TEXT, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX idx_system_configs_key ON system_configs(config_key); + +-- 创建更新时间触发器 +CREATE TRIGGER update_system_configs_updated_at BEFORE UPDATE ON system_configs + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/server/migrations/20250120000049_add_foreign_keys.sql b/server/migrations/20250120000049_add_foreign_keys.sql new file mode 100644 index 00000000..828dcb8c --- /dev/null +++ b/server/migrations/20250120000049_add_foreign_keys.sql @@ -0,0 +1,32 @@ +-- 添加延迟创建的外键约束 +-- 某些外键需要在相关表都创建后才能添加 + +-- teams.default_proxy_uuid -> proxies.uuid +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_teams_default_proxy' + ) THEN + ALTER TABLE teams + ADD CONSTRAINT fk_teams_default_proxy + FOREIGN KEY (default_proxy_uuid) REFERENCES proxies(uuid) ON DELETE SET NULL; + END IF; +END $$; + +-- groups.default_proxy_uuid -> proxies.uuid +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_groups_default_proxy' + ) THEN + ALTER TABLE groups + ADD CONSTRAINT fk_groups_default_proxy + FOREIGN KEY (default_proxy_uuid) REFERENCES proxies(uuid) ON DELETE SET NULL; + END IF; +END $$; + +-- 创建 groups.default_proxy_uuid 索引 +CREATE INDEX IF NOT EXISTS idx_groups_default_proxy ON groups(default_proxy_uuid); + diff --git a/server/migrations/20250120000050_insert_default_data.sql b/server/migrations/20250120000050_insert_default_data.sql new file mode 100644 index 00000000..c058ba49 --- /dev/null +++ b/server/migrations/20250120000050_insert_default_data.sql @@ -0,0 +1,37 @@ +-- 插入默认数据 + +-- 1. 默认套餐 +INSERT INTO plans (uuid, name, description, price_per_month, price_per_year, max_environments, max_team_members, max_proxies, max_rpa_tasks, is_recommended, sort_order, status) +VALUES + (gen_random_uuid(), 'Free', '免费套餐,适合个人试用', 0, 0, 5, 1, 5, 2, FALSE, 1, 'active'), + (gen_random_uuid(), 'Basic', '基础套餐,适合个人用户', 9.99, 99.99, 20, 3, 20, 5, FALSE, 2, 'active'), + (gen_random_uuid(), 'Pro', '专业套餐,适合小型团队', 29.99, 299.99, 100, 10, 100, 20, TRUE, 3, 'active'), + (gen_random_uuid(), 'Enterprise', '企业套餐,无限制', 99.99, 999.99, 1000, 100, 1000, 100, FALSE, 4, 'active') +ON CONFLICT DO NOTHING; + +-- 2. 默认推广链接层级 +INSERT INTO referral_link_tiers (uuid, name, unlock_threshold, reward_rate, discount_rate, description, sort_order) +VALUES + (gen_random_uuid(), '青铜', 0, 10.00, 5.00, '默认层级,邀请即享', 1), + (gen_random_uuid(), '白银', 10, 15.00, 8.00, '邀请满10人解锁', 2), + (gen_random_uuid(), '黄金', 50, 20.00, 10.00, '邀请满50人解锁', 3), + (gen_random_uuid(), '钻石', 100, 25.00, 15.00, '邀请满100人解锁', 4) +ON CONFLICT DO NOTHING; + +-- 3. 默认兑换选项 +INSERT INTO redeem_options (uuid, redeem_type, name, description, points_required, value, exchange_rate, status, sort_order) +VALUES + (gen_random_uuid(), 'wallet', '兑换余额', '100 积分 = $1', 100, 1.00, 100, 'active', 1), + (gen_random_uuid(), 'coupon', '兑换优惠券', '500 积分 = $10 优惠券', 500, 10.00, 50, 'active', 2), + (gen_random_uuid(), 'coupon', '兑换优惠券', '1000 积分 = $25 优惠券', 1000, 25.00, 40, 'active', 3) +ON CONFLICT DO NOTHING; + +-- 4. 默认系统配置 +INSERT INTO system_configs (config_key, config_value, description) +VALUES + ('app_version', '"1.0.0"', '应用版本'), + ('maintenance_mode', 'false', '维护模式'), + ('registration_enabled', 'true', '是否允许注册'), + ('referral_enabled', 'true', '是否启用推广计划') +ON CONFLICT (config_key) DO NOTHING; + diff --git a/server/migrations/20250120000051_remove_icon_and_color_fields.sql b/server/migrations/20250120000051_remove_icon_and_color_fields.sql new file mode 100644 index 00000000..8b1a178c --- /dev/null +++ b/server/migrations/20250120000051_remove_icon_and_color_fields.sql @@ -0,0 +1,10 @@ +-- 移除环境的 icon 和 icon_color 字段 +-- 移除分组的 color 字段 + +-- 移除 environments 表的 icon 和 icon_color 字段 +ALTER TABLE environments DROP COLUMN IF EXISTS icon; +ALTER TABLE environments DROP COLUMN IF EXISTS icon_color; + +-- 移除 groups 表的 color 字段 +ALTER TABLE groups DROP COLUMN IF EXISTS color; + diff --git a/server/migrations/20250123000001_create_console_admins.sql b/server/migrations/20250123000001_create_console_admins.sql new file mode 100644 index 00000000..bc530e80 --- /dev/null +++ b/server/migrations/20250123000001_create_console_admins.sql @@ -0,0 +1,28 @@ +-- Console Gateway 管理员表 +-- 用于存储 console-gateway 的管理员信息 + +CREATE TABLE IF NOT EXISTS console_admins ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL UNIQUE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- 索引 +CREATE INDEX IF NOT EXISTS idx_console_admins_user_uuid ON console_admins(user_uuid); +CREATE INDEX IF NOT EXISTS idx_console_admins_is_active ON console_admins(is_active); +CREATE INDEX IF NOT EXISTS idx_console_admins_deleted_at ON console_admins(deleted_at); + +-- 注释 +COMMENT ON TABLE console_admins IS 'Console Gateway 管理员表'; +COMMENT ON COLUMN console_admins.id IS '主键 ID'; +COMMENT ON COLUMN console_admins.uuid IS '唯一标识'; +COMMENT ON COLUMN console_admins.user_uuid IS '关联的用户 UUID'; +COMMENT ON COLUMN console_admins.is_active IS '是否激活'; +COMMENT ON COLUMN console_admins.created_at IS '创建时间'; +COMMENT ON COLUMN console_admins.updated_at IS '更新时间'; +COMMENT ON COLUMN console_admins.deleted_at IS '删除时间 (软删除)'; + diff --git a/server/migrations/20250123000002_create_console_permissions.sql b/server/migrations/20250123000002_create_console_permissions.sql new file mode 100644 index 00000000..4434cea4 --- /dev/null +++ b/server/migrations/20250123000002_create_console_permissions.sql @@ -0,0 +1,51 @@ +-- Console Gateway 权限表 +-- 用于存储 console-gateway 的路由权限 + +CREATE TABLE IF NOT EXISTS console_permissions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + route_path VARCHAR(512) NOT NULL, + method VARCHAR(10) NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + UNIQUE(route_path, method) +); + +-- Console Gateway 管理员权限关联表 +CREATE TABLE IF NOT EXISTS console_admin_permissions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + admin_id INTEGER NOT NULL REFERENCES console_admins(id) ON DELETE CASCADE, + permission_id INTEGER NOT NULL REFERENCES console_permissions(id) ON DELETE CASCADE, + granted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + granted_by UUID, + UNIQUE(admin_id, permission_id) +); + +-- 索引 +CREATE INDEX IF NOT EXISTS idx_console_permissions_route ON console_permissions(route_path, method); +CREATE INDEX IF NOT EXISTS idx_console_permissions_deleted_at ON console_permissions(deleted_at); +CREATE INDEX IF NOT EXISTS idx_console_admin_permissions_admin ON console_admin_permissions(admin_id); +CREATE INDEX IF NOT EXISTS idx_console_admin_permissions_permission ON console_admin_permissions(permission_id); + +-- 注释 +COMMENT ON TABLE console_permissions IS 'Console Gateway 路由权限表'; +COMMENT ON COLUMN console_permissions.id IS '主键 ID'; +COMMENT ON COLUMN console_permissions.uuid IS '唯一标识'; +COMMENT ON COLUMN console_permissions.route_path IS '路由路径'; +COMMENT ON COLUMN console_permissions.method IS 'HTTP 方法 (GET, POST, PUT, DELETE, PATCH)'; +COMMENT ON COLUMN console_permissions.description IS '权限描述'; +COMMENT ON COLUMN console_permissions.created_at IS '创建时间'; +COMMENT ON COLUMN console_permissions.updated_at IS '更新时间'; +COMMENT ON COLUMN console_permissions.deleted_at IS '删除时间 (软删除)'; + +COMMENT ON TABLE console_admin_permissions IS 'Console Gateway 管理员权限关联表'; +COMMENT ON COLUMN console_admin_permissions.id IS '主键 ID'; +COMMENT ON COLUMN console_admin_permissions.uuid IS '唯一标识'; +COMMENT ON COLUMN console_admin_permissions.admin_id IS '管理员 ID'; +COMMENT ON COLUMN console_admin_permissions.permission_id IS '权限 ID'; +COMMENT ON COLUMN console_admin_permissions.granted_at IS '授权时间'; +COMMENT ON COLUMN console_admin_permissions.granted_by IS '授权人 UUID'; + diff --git a/server/migrations/20250123000003_create_console_api_keys.sql b/server/migrations/20250123000003_create_console_api_keys.sql new file mode 100644 index 00000000..a923c1ce --- /dev/null +++ b/server/migrations/20250123000003_create_console_api_keys.sql @@ -0,0 +1,41 @@ +-- Console Gateway API 密钥表 +-- 用于存储 console-gateway 的 API 密钥 + +CREATE TABLE IF NOT EXISTS console_api_keys ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + key_id VARCHAR(64) NOT NULL UNIQUE, + key_secret VARCHAR(128) NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE, + last_used_at TIMESTAMP WITH TIME ZONE, + created_by UUID, + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- 索引 +CREATE INDEX IF NOT EXISTS idx_console_api_keys_key_id ON console_api_keys(key_id); +CREATE INDEX IF NOT EXISTS idx_console_api_keys_is_active ON console_api_keys(is_active); +CREATE INDEX IF NOT EXISTS idx_console_api_keys_expires_at ON console_api_keys(expires_at); +CREATE INDEX IF NOT EXISTS idx_console_api_keys_deleted_at ON console_api_keys(deleted_at); + +-- 注释 +COMMENT ON TABLE console_api_keys IS 'Console Gateway API 密钥表'; +COMMENT ON COLUMN console_api_keys.id IS '主键 ID'; +COMMENT ON COLUMN console_api_keys.uuid IS '唯一标识'; +COMMENT ON COLUMN console_api_keys.key_id IS 'API 密钥 ID (公开部分)'; +COMMENT ON COLUMN console_api_keys.key_secret IS 'API 密钥密文 (私密部分)'; +COMMENT ON COLUMN console_api_keys.name IS '密钥名称'; +COMMENT ON COLUMN console_api_keys.description IS '密钥描述'; +COMMENT ON COLUMN console_api_keys.is_active IS '是否激活'; +COMMENT ON COLUMN console_api_keys.created_at IS '创建时间'; +COMMENT ON COLUMN console_api_keys.updated_at IS '更新时间'; +COMMENT ON COLUMN console_api_keys.expires_at IS '过期时间'; +COMMENT ON COLUMN console_api_keys.last_used_at IS '最后使用时间'; +COMMENT ON COLUMN console_api_keys.created_by IS '创建人 UUID'; +COMMENT ON COLUMN console_api_keys.deleted_at IS '删除时间 (软删除)'; + diff --git a/server/migrations/20250124000001_create_messages.sql b/server/migrations/20250124000001_create_messages.sql new file mode 100644 index 00000000..e904ff04 --- /dev/null +++ b/server/migrations/20250124000001_create_messages.sql @@ -0,0 +1,148 @@ +-- 创建 messages 表 +-- 消息表,用于存储系统消息、团队邀请通知等 + +CREATE TABLE IF NOT EXISTS messages ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + + -- 消息类型 + message_type VARCHAR(50) NOT NULL, + -- 可选值: + -- 'private_chat' - 用户私信 + -- 'team_announcement' - 团队公告 + -- 'team_invitation' - 团队邀请 + -- 'team_removal' - 团队移除成员 + -- 'system_notification' - 系统通知 + + -- 消息内容 + title VARCHAR(255) NOT NULL, -- 消息标题 + content TEXT, -- 消息内容(支持 JSON 格式存储扩展数据) + + -- 发送者 + sender_uuid UUID, -- 发送者 UUID(系统消息可为 NULL) + + -- 接收者模式 + recipient_type VARCHAR(20) NOT NULL DEFAULT 'single', + -- 'single' - 单个用户 + -- 'multiple' - 多个指定用户 + -- 'team' - 团队内所有成员 + -- 'all' - 所有用户(系统广播) + + -- 关联资源(根据消息类型关联不同的资源) + related_type VARCHAR(50), -- 关联类型:team, invitation, etc. + related_uuid UUID, -- 关联资源的 UUID(如 team_uuid, invitation_uuid) + + -- 消息元数据(JSON 格式,存储扩展信息) + metadata JSONB, + -- 例如:{ + -- "team_name": "研发团队", + -- "inviter_name": "张三", + -- "role": "editor" + -- } + + -- 消息状态 + status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, deleted + priority VARCHAR(20) NOT NULL DEFAULT 'normal', -- low, normal, high, urgent + + -- 时间戳 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + + -- 外键约束 + CONSTRAINT fk_messages_sender FOREIGN KEY (sender_uuid) + REFERENCES users(uuid) ON DELETE SET NULL +); + +-- 创建 user_messages 表(用户消息关联表,支持多接收者) +CREATE TABLE IF NOT EXISTS user_messages ( + id SERIAL PRIMARY KEY, + message_uuid UUID NOT NULL, + user_uuid UUID NOT NULL, + + -- 阅读状态 + is_read BOOLEAN NOT NULL DEFAULT FALSE, + read_at TIMESTAMP WITH TIME ZONE, + + -- 操作状态(用于邀请、移除等需要操作的消息) + action_status VARCHAR(20), + -- 'pending' - 待处理(邀请类消息) + -- 'accepted' - 已接受 + -- 'rejected' - 已拒绝 + -- 'expired' - 已过期 + -- NULL - 无需操作的消息 + + action_at TIMESTAMP WITH TIME ZONE, + + -- 时间戳 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- 外键约束 + CONSTRAINT fk_user_messages_message FOREIGN KEY (message_uuid) + REFERENCES messages(uuid) ON DELETE CASCADE, + CONSTRAINT fk_user_messages_user FOREIGN KEY (user_uuid) + REFERENCES users(uuid) ON DELETE CASCADE, + + -- 唯一约束:一个用户对一条消息只能有一条记录 + CONSTRAINT uk_user_messages_message_user UNIQUE (message_uuid, user_uuid) +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_messages_type ON messages(message_type); +CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_uuid); +CREATE INDEX IF NOT EXISTS idx_messages_related ON messages(related_type, related_uuid); +CREATE INDEX IF NOT EXISTS idx_messages_recipient_type ON messages(recipient_type); +CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status); +CREATE INDEX IF NOT EXISTS idx_messages_priority ON messages(priority); +CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_messages_deleted_at ON messages(deleted_at); + +CREATE INDEX IF NOT EXISTS idx_user_messages_user ON user_messages(user_uuid); +CREATE INDEX IF NOT EXISTS idx_user_messages_message ON user_messages(message_uuid); +CREATE INDEX IF NOT EXISTS idx_user_messages_is_read ON user_messages(user_uuid, is_read); +CREATE INDEX IF NOT EXISTS idx_user_messages_action_status ON user_messages(user_uuid, action_status); +CREATE INDEX IF NOT EXISTS idx_user_messages_user_unread ON user_messages(user_uuid, is_read) + WHERE is_read = FALSE; + +-- 复合索引:用于查询用户未读消息 +CREATE INDEX IF NOT EXISTS idx_user_messages_user_type_unread + ON user_messages(user_uuid, is_read, created_at DESC) + WHERE is_read = FALSE; + +-- 创建更新时间触发器 +CREATE TRIGGER update_messages_updated_at BEFORE UPDATE ON messages + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_user_messages_updated_at BEFORE UPDATE ON user_messages + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- 团队消息自动分发触发器 +-- 当创建 recipient_type='team' 的消息时,自动为团队成员创建 user_messages 记录 +CREATE OR REPLACE FUNCTION auto_create_team_message_recipients() +RETURNS TRIGGER AS $$ +BEGIN + -- 如果接收者类型是 team,且有关联的团队 UUID + IF NEW.recipient_type = 'team' AND NEW.related_type = 'team' AND NEW.related_uuid IS NOT NULL THEN + -- 为团队所有活跃成员创建消息关联记录 + INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) + SELECT NEW.uuid, tm.user_uuid, FALSE, + CASE + WHEN NEW.message_type = 'team_invitation' THEN 'pending' + ELSE NULL + END + FROM team_members tm + WHERE tm.team_uuid = NEW.related_uuid + AND tm.status = 'active' + AND tm.deleted_at IS NULL + ON CONFLICT (message_uuid, user_uuid) DO NOTHING; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_auto_create_team_message_recipients + AFTER INSERT ON messages + FOR EACH ROW + EXECUTE FUNCTION auto_create_team_message_recipients(); + diff --git a/server/migrations/20250124000002_add_deleted_at_to_team_invitations.sql b/server/migrations/20250124000002_add_deleted_at_to_team_invitations.sql new file mode 100644 index 00000000..f96eab0c --- /dev/null +++ b/server/migrations/20250124000002_add_deleted_at_to_team_invitations.sql @@ -0,0 +1,9 @@ +-- 为 team_invitations 表添加 deleted_at 字段 +-- 用于软删除功能 + +ALTER TABLE team_invitations +ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE; + +-- 创建索引以优化查询性能 +CREATE INDEX IF NOT EXISTS idx_invitations_deleted_at ON team_invitations(deleted_at) WHERE deleted_at IS NULL; + diff --git a/server/migrations/20250125000001_create_workspaces.sql b/server/migrations/20250125000001_create_workspaces.sql new file mode 100644 index 00000000..1bf948e9 --- /dev/null +++ b/server/migrations/20250125000001_create_workspaces.sql @@ -0,0 +1,34 @@ +-- 创建 workspaces 表 +-- 工作空间表,资源隔离的顶层容器 + +CREATE TABLE IF NOT EXISTS workspaces ( + uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + owner_uuid UUID NOT NULL, + workspace_type VARCHAR(50) NOT NULL DEFAULT 'personal', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_workspaces_owner FOREIGN KEY (owner_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_workspaces_owner_uuid ON workspaces(owner_uuid); +CREATE INDEX idx_workspaces_deleted_at ON workspaces(deleted_at); +CREATE INDEX idx_workspaces_workspace_type ON workspaces(workspace_type); + +-- 创建更新时间触发器 +CREATE TRIGGER update_workspaces_updated_at BEFORE UPDATE ON workspaces + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- 列注释 +COMMENT ON TABLE workspaces IS '工作空间表,资源隔离的顶层容器'; +COMMENT ON COLUMN workspaces.uuid IS '工作空间唯一标识'; +COMMENT ON COLUMN workspaces.name IS '工作空间名称'; +COMMENT ON COLUMN workspaces.owner_uuid IS '所有者用户 UUID'; +COMMENT ON COLUMN workspaces.workspace_type IS '工作空间类型:personal/team/enterprise'; +COMMENT ON COLUMN workspaces.created_at IS '创建时间'; +COMMENT ON COLUMN workspaces.updated_at IS '更新时间'; +COMMENT ON COLUMN workspaces.deleted_at IS '删除时间(软删除)'; + diff --git a/server/migrations/20250125000002_create_workspace_quotas.sql b/server/migrations/20250125000002_create_workspace_quotas.sql new file mode 100644 index 00000000..e1952278 --- /dev/null +++ b/server/migrations/20250125000002_create_workspace_quotas.sql @@ -0,0 +1,43 @@ +-- 创建 workspace_quotas 表 +-- 工作空间配额表,定义工作空间的资源配额限制 + +CREATE TABLE IF NOT EXISTS workspace_quotas ( + workspace_uuid UUID PRIMARY KEY, + -- 环境配额 + max_environments INT NOT NULL DEFAULT 10, + used_environments INT NOT NULL DEFAULT 0, + -- 团队成员配额(所有团队总和) + max_team_members INT NOT NULL DEFAULT 5, + used_team_members INT NOT NULL DEFAULT 0, + -- 代理配额 + max_proxies INT NOT NULL DEFAULT 10, + used_proxies INT NOT NULL DEFAULT 0, + -- RPA 任务配额 + max_rpa_tasks INT NOT NULL DEFAULT 5, + used_rpa_tasks INT NOT NULL DEFAULT 0, + -- 时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_workspace_quotas_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_workspace_quotas_workspace_uuid ON workspace_quotas(workspace_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_workspace_quotas_updated_at BEFORE UPDATE ON workspace_quotas + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- 列注释 +COMMENT ON TABLE workspace_quotas IS '工作空间配额表,定义工作空间的资源配额限制'; +COMMENT ON COLUMN workspace_quotas.workspace_uuid IS '工作空间 UUID'; +COMMENT ON COLUMN workspace_quotas.max_environments IS '最大环境数'; +COMMENT ON COLUMN workspace_quotas.used_environments IS '已使用环境数'; +COMMENT ON COLUMN workspace_quotas.max_team_members IS '最大成员数(所有团队总和)'; +COMMENT ON COLUMN workspace_quotas.used_team_members IS '已使用成员数'; +COMMENT ON COLUMN workspace_quotas.max_proxies IS '最大代理数'; +COMMENT ON COLUMN workspace_quotas.used_proxies IS '已使用代理数'; +COMMENT ON COLUMN workspace_quotas.max_rpa_tasks IS '最大 RPA 任务数'; +COMMENT ON COLUMN workspace_quotas.used_rpa_tasks IS '已使用 RPA 任务数'; + diff --git a/server/migrations/20250125000003_create_proxy_visible_teams.sql b/server/migrations/20250125000003_create_proxy_visible_teams.sql new file mode 100644 index 00000000..353955dd --- /dev/null +++ b/server/migrations/20250125000003_create_proxy_visible_teams.sql @@ -0,0 +1,28 @@ +-- 创建 proxy_visible_teams 表 +-- 代理可见团队关联表,控制代理对哪些团队可见 + +CREATE TABLE IF NOT EXISTS proxy_visible_teams ( + proxy_uuid UUID NOT NULL, + workspace_uuid UUID NOT NULL, + team_uuid UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_proxy_visible_teams_proxy FOREIGN KEY (proxy_uuid) REFERENCES proxies(uuid) ON DELETE CASCADE, + CONSTRAINT fk_proxy_visible_teams_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE, + CONSTRAINT fk_proxy_visible_teams_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + -- 唯一约束:一个代理对一个团队只能有一条可见性记录 + CONSTRAINT uk_proxy_visible_teams UNIQUE (proxy_uuid, team_uuid) +); + +-- 创建索引 +CREATE INDEX idx_proxy_visible_teams_proxy_uuid ON proxy_visible_teams(proxy_uuid); +CREATE INDEX idx_proxy_visible_teams_team_uuid ON proxy_visible_teams(team_uuid); +CREATE INDEX idx_proxy_visible_teams_workspace_uuid ON proxy_visible_teams(workspace_uuid); + +-- 列注释 +COMMENT ON TABLE proxy_visible_teams IS '代理可见团队关联表,控制代理对哪些团队可见'; +COMMENT ON COLUMN proxy_visible_teams.proxy_uuid IS '代理 UUID'; +COMMENT ON COLUMN proxy_visible_teams.workspace_uuid IS '工作空间 UUID(冗余,便于查询)'; +COMMENT ON COLUMN proxy_visible_teams.team_uuid IS '团队 UUID'; +COMMENT ON COLUMN proxy_visible_teams.created_at IS '创建时间'; + diff --git a/server/migrations/20250125000004_create_group_member_permissions.sql b/server/migrations/20250125000004_create_group_member_permissions.sql new file mode 100644 index 00000000..7d654197 --- /dev/null +++ b/server/migrations/20250125000004_create_group_member_permissions.sql @@ -0,0 +1,45 @@ +-- 创建 group_member_permissions 表 +-- 分组权限表,控制团队成员对分组的访问权限 + +CREATE TABLE IF NOT EXISTS group_member_permissions ( + group_uuid UUID NOT NULL, + workspace_uuid UUID NOT NULL, + team_uuid UUID NOT NULL, + user_uuid UUID NOT NULL, + permission_type VARCHAR(50) NOT NULL DEFAULT 'read', + granted_by UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_group_member_permissions_group FOREIGN KEY (group_uuid) REFERENCES groups(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_granted_by FOREIGN KEY (granted_by) REFERENCES users(uuid), + -- 唯一约束:一个用户对一个分组只能有一条权限记录 + CONSTRAINT uk_group_member_permissions UNIQUE (group_uuid, user_uuid), + -- 检查约束:权限类型必须是 read/write/manage 之一 + CONSTRAINT ck_group_member_permissions_type CHECK (permission_type IN ('read', 'write', 'manage')) +); + +-- 创建索引 +CREATE INDEX idx_group_member_permissions_group_uuid ON group_member_permissions(group_uuid); +CREATE INDEX idx_group_member_permissions_user_uuid ON group_member_permissions(user_uuid); +CREATE INDEX idx_group_member_permissions_workspace_uuid ON group_member_permissions(workspace_uuid); +CREATE INDEX idx_group_member_permissions_team_uuid ON group_member_permissions(team_uuid); + +-- 创建更新时间触发器 +CREATE TRIGGER update_group_member_permissions_updated_at BEFORE UPDATE ON group_member_permissions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- 列注释 +COMMENT ON TABLE group_member_permissions IS '分组权限表,控制团队成员对分组的访问权限'; +COMMENT ON COLUMN group_member_permissions.group_uuid IS '分组 UUID'; +COMMENT ON COLUMN group_member_permissions.workspace_uuid IS '工作空间 UUID(冗余,便于查询)'; +COMMENT ON COLUMN group_member_permissions.team_uuid IS '团队 UUID(冗余,便于查询)'; +COMMENT ON COLUMN group_member_permissions.user_uuid IS '用户 UUID'; +COMMENT ON COLUMN group_member_permissions.permission_type IS '权限类型:read/write/manage'; +COMMENT ON COLUMN group_member_permissions.granted_by IS '授权者 UUID'; +COMMENT ON COLUMN group_member_permissions.created_at IS '创建时间'; +COMMENT ON COLUMN group_member_permissions.updated_at IS '更新时间'; + diff --git a/server/migrations/20250125000005_alter_teams_add_workspace.sql b/server/migrations/20250125000005_alter_teams_add_workspace.sql new file mode 100644 index 00000000..7c067551 --- /dev/null +++ b/server/migrations/20250125000005_alter_teams_add_workspace.sql @@ -0,0 +1,19 @@ +-- 修改 teams 表,添加工作空间支持 +-- 添加 workspace_uuid,移除配额相关字段和默认代理 + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE teams ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_teams_workspace_uuid ON teams(workspace_uuid); + +-- 移除配额相关字段(配额移至 workspace_quotas) +ALTER TABLE teams DROP COLUMN IF EXISTS max_members; +ALTER TABLE teams DROP COLUMN IF EXISTS max_environments; +ALTER TABLE teams DROP COLUMN IF EXISTS max_proxies; + +-- 移除默认代理字段(不再需要默认代理) +ALTER TABLE teams DROP COLUMN IF EXISTS default_proxy_uuid; + +-- 注意:workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 + diff --git a/server/migrations/20250125000006_alter_team_members_add_workspace.sql b/server/migrations/20250125000006_alter_team_members_add_workspace.sql new file mode 100644 index 00000000..ea5c0862 --- /dev/null +++ b/server/migrations/20250125000006_alter_team_members_add_workspace.sql @@ -0,0 +1,21 @@ +-- 修改 team_members 表,添加工作空间支持 +-- 添加 workspace_uuid(冗余字段),移除统计字段 + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE team_members ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_team_members_workspace_uuid ON team_members(workspace_uuid); + +-- 移除统计字段(可通过查询计算) +ALTER TABLE team_members DROP COLUMN IF EXISTS environment_count; +ALTER TABLE team_members DROP COLUMN IF EXISTS group_count; + +-- 删除旧的唯一约束 +ALTER TABLE team_members DROP CONSTRAINT IF EXISTS uk_team_members; + +-- 添加新的唯一约束(包含 workspace_uuid) +ALTER TABLE team_members ADD CONSTRAINT uk_team_members UNIQUE (team_uuid, user_uuid, workspace_uuid); + +-- 注意:workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 + diff --git a/server/migrations/20250125000007_alter_groups_add_workspace.sql b/server/migrations/20250125000007_alter_groups_add_workspace.sql new file mode 100644 index 00000000..96e83bd4 --- /dev/null +++ b/server/migrations/20250125000007_alter_groups_add_workspace.sql @@ -0,0 +1,25 @@ +-- 修改 groups 表,添加工作空间支持 +-- 添加 workspace_uuid,移除 user_uuid, default_proxy_uuid, color,确保 team_uuid NOT NULL + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE groups ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_groups_workspace_uuid ON groups(workspace_uuid); + +-- 移除 user_uuid(分组属于团队,不再直接属于用户) +ALTER TABLE groups DROP COLUMN IF EXISTS user_uuid; + +-- 移除默认代理字段(不再需要默认代理) +ALTER TABLE groups DROP COLUMN IF EXISTS default_proxy_uuid; + +-- 移除 color 字段(简化设计) +ALTER TABLE groups DROP COLUMN IF EXISTS color; + +-- 删除旧的索引 +DROP INDEX IF EXISTS idx_groups_user_uuid; + +-- 注意: +-- 1. workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 +-- 2. team_uuid 的 NOT NULL 约束将在数据迁移后添加(确保所有数据都有 team_uuid) + diff --git a/server/migrations/20250125000008_alter_environments_add_workspace.sql b/server/migrations/20250125000008_alter_environments_add_workspace.sql new file mode 100644 index 00000000..92819d7e --- /dev/null +++ b/server/migrations/20250125000008_alter_environments_add_workspace.sql @@ -0,0 +1,13 @@ +-- 修改 environments 表,添加工作空间支持 +-- 添加 workspace_uuid,确保 team_uuid NOT NULL + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE environments ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_environments_workspace_uuid ON environments(workspace_uuid); + +-- 注意: +-- 1. workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 +-- 2. team_uuid 的 NOT NULL 约束将在数据迁移后添加(确保所有数据都有 team_uuid) + diff --git a/server/migrations/20250125000009_alter_proxies_add_workspace.sql b/server/migrations/20250125000009_alter_proxies_add_workspace.sql new file mode 100644 index 00000000..4fcedf0c --- /dev/null +++ b/server/migrations/20250125000009_alter_proxies_add_workspace.sql @@ -0,0 +1,29 @@ +-- 修改 proxies 表,添加工作空间支持 +-- 添加 workspace_uuid 和 owner_uuid(重命名自 user_uuid),移除 team_uuid, usage_count + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE proxies ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 添加 owner_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE proxies ADD COLUMN IF NOT EXISTS owner_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_proxies_workspace_uuid ON proxies(workspace_uuid); +CREATE INDEX IF NOT EXISTS idx_proxies_owner_uuid ON proxies(owner_uuid); + +-- 将 user_uuid 的数据复制到 owner_uuid(如果 owner_uuid 为空) +UPDATE proxies SET owner_uuid = user_uuid WHERE owner_uuid IS NULL; + +-- 移除 team_uuid(代理属于工作空间,不属于团队) +ALTER TABLE proxies DROP COLUMN IF EXISTS team_uuid; + +-- 移除 usage_count(可通过查询计算) +ALTER TABLE proxies DROP COLUMN IF EXISTS usage_count; + +-- 删除旧的索引 +DROP INDEX IF EXISTS idx_proxies_team_uuid; + +-- 注意: +-- 1. workspace_uuid 和 owner_uuid 的外键约束将在数据迁移脚本中完成 +-- 2. user_uuid 列将在数据迁移后删除(迁移到 owner_uuid) + diff --git a/server/migrations/20250125000010_alter_subscriptions_add_workspace.sql b/server/migrations/20250125000010_alter_subscriptions_add_workspace.sql new file mode 100644 index 00000000..eb6a47dd --- /dev/null +++ b/server/migrations/20250125000010_alter_subscriptions_add_workspace.sql @@ -0,0 +1,13 @@ +-- 修改 subscriptions 表,添加工作空间支持 +-- 添加 workspace_uuid,保留 user_uuid(用于记录订阅者) + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS workspace_uuid UUID; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_subscriptions_workspace_uuid ON subscriptions(workspace_uuid); + +-- 注意: +-- 1. workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 +-- 2. 唯一约束(确保一个工作空间只有一个活跃订阅)将在数据迁移后添加 + diff --git a/server/migrations/20250125000011_deprecate_user_quotas.sql b/server/migrations/20250125000011_deprecate_user_quotas.sql new file mode 100644 index 00000000..294fc7aa --- /dev/null +++ b/server/migrations/20250125000011_deprecate_user_quotas.sql @@ -0,0 +1,8 @@ +-- 废弃 user_quotas 表 +-- 保留表结构,但标记为废弃,数据将迁移到 workspace_quotas + +-- 添加注释标记为废弃 +COMMENT ON TABLE user_quotas IS '已废弃:配额已迁移到 workspace_quotas 表,此表保留仅用于历史数据查询'; + +-- 注意:表结构保持不变,数据迁移将在数据迁移脚本中完成 + diff --git a/server/migrations/20250125000012_migrate_to_workspaces.sql b/server/migrations/20250125000012_migrate_to_workspaces.sql new file mode 100644 index 00000000..3140a0c2 --- /dev/null +++ b/server/migrations/20250125000012_migrate_to_workspaces.sql @@ -0,0 +1,416 @@ +-- 数据迁移脚本:将现有数据迁移到工作空间架构 +-- 此脚本将: +-- 1. 为每个用户创建默认工作空间(personal 类型) +-- 2. 为每个现有团队创建对应的工作空间(team 类型) +-- 3. 迁移配额数据从 user_quotas 到 workspace_quotas +-- 4. 更新所有相关表的外键关系 + +BEGIN; + +-- ============================================ +-- 步骤 1: 为每个用户创建默认工作空间 +-- ============================================ +INSERT INTO workspaces (uuid, name, owner_uuid, workspace_type, created_at, updated_at) +SELECT + gen_random_uuid(), + COALESCE(ui.nickname, ui.email, '我的工作空间') || ' 的工作空间', + u.uuid, + 'personal', + u.created_at, + u.updated_at +FROM users u +LEFT JOIN user_infos ui ON u.uuid = ui.user_uuid +WHERE u.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.owner_uuid = u.uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + ); + +-- ============================================ +-- 步骤 2: 为每个现有团队创建对应的工作空间 +-- ============================================ +INSERT INTO workspaces (uuid, name, owner_uuid, workspace_type, created_at, updated_at) +SELECT + gen_random_uuid(), + t.name || ' 的工作空间', + t.owner_uuid, + 'team', + t.created_at, + t.updated_at +FROM teams t +WHERE t.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.owner_uuid = t.owner_uuid + AND w.name = t.name || ' 的工作空间' + AND w.deleted_at IS NULL + ); + +-- ============================================ +-- 步骤 3: 更新 teams 表的 workspace_uuid +-- ============================================ +-- 为每个团队分配工作空间(优先使用团队对应的工作空间,如果没有则使用团队所有者的个人工作空间) +UPDATE teams t +SET workspace_uuid = COALESCE( + (SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = t.owner_uuid + AND w.workspace_type = 'team' + AND w.name = t.name || ' 的工作空间' + AND w.deleted_at IS NULL + LIMIT 1), + (SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = t.owner_uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1) +) +WHERE t.workspace_uuid IS NULL + AND t.deleted_at IS NULL; + +-- 设置 workspace_uuid 为 NOT NULL +ALTER TABLE teams ALTER COLUMN workspace_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE teams +ADD CONSTRAINT fk_teams_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- ============================================ +-- 步骤 4: 更新 team_members 表的 workspace_uuid +-- ============================================ +UPDATE team_members tm +SET workspace_uuid = t.workspace_uuid +FROM teams t +WHERE tm.team_uuid = t.uuid + AND tm.workspace_uuid IS NULL + AND tm.deleted_at IS NULL; + +-- 设置 workspace_uuid 为 NOT NULL +ALTER TABLE team_members ALTER COLUMN workspace_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE team_members +ADD CONSTRAINT fk_team_members_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- ============================================ +-- 步骤 5: 更新 groups 表的 workspace_uuid +-- ============================================ +-- 首先更新有 team_uuid 的分组 +UPDATE groups g +SET workspace_uuid = t.workspace_uuid +FROM teams t +WHERE g.team_uuid = t.uuid + AND g.workspace_uuid IS NULL + AND g.deleted_at IS NULL; + +-- 对于没有 team_uuid 的分组,先分配一个默认团队 +UPDATE groups g +SET team_uuid = ( + SELECT t.uuid FROM teams t + INNER JOIN workspaces w ON t.workspace_uuid = w.uuid + WHERE w.owner_uuid = ( + -- 尝试从 environments 表找到关联的用户 + SELECT e.user_uuid FROM environments e + WHERE e.group_uuid = g.uuid + AND e.deleted_at IS NULL + LIMIT 1 + ) + AND t.deleted_at IS NULL + LIMIT 1 +) +WHERE g.team_uuid IS NULL + AND g.deleted_at IS NULL; + +-- 再次更新 workspace_uuid(包括刚刚分配了 team_uuid 的分组) +UPDATE groups g +SET workspace_uuid = t.workspace_uuid +FROM teams t +WHERE g.team_uuid = t.uuid + AND g.workspace_uuid IS NULL + AND g.deleted_at IS NULL; + +-- 对于仍然没有 workspace_uuid 的分组,使用用户的个人工作空间 +UPDATE groups g +SET workspace_uuid = ( + SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = ( + SELECT e.user_uuid FROM environments e + WHERE e.group_uuid = g.uuid + AND e.deleted_at IS NULL + LIMIT 1 + ) + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1 +) +WHERE g.workspace_uuid IS NULL + AND g.deleted_at IS NULL; + +-- 如果还有 NULL 值,使用第一个可用的工作空间(兜底方案) +UPDATE groups g +SET workspace_uuid = ( + SELECT w.uuid FROM workspaces w + WHERE w.deleted_at IS NULL + ORDER BY w.created_at + LIMIT 1 +) +WHERE g.workspace_uuid IS NULL + AND g.deleted_at IS NULL; + +-- 验证:确保所有分组都有 workspace_uuid +DO $$ +DECLARE + null_count INTEGER; +BEGIN + SELECT COUNT(*) INTO null_count + FROM groups + WHERE workspace_uuid IS NULL + AND deleted_at IS NULL; + + IF null_count > 0 THEN + RAISE EXCEPTION '仍有 % 个分组的 workspace_uuid 为 NULL,请手动处理', null_count; + END IF; +END $$; + +-- 设置 workspace_uuid 为 NOT NULL +ALTER TABLE groups ALTER COLUMN workspace_uuid SET NOT NULL; + +-- 确保 team_uuid 为 NOT NULL(删除没有 team_uuid 的分组或分配默认团队) +-- 注意:这里假设所有分组都应该有 team_uuid,如果没有则可能需要手动处理 +UPDATE groups g +SET team_uuid = ( + SELECT t.uuid FROM teams t + INNER JOIN workspaces w ON t.workspace_uuid = w.uuid + WHERE w.owner_uuid = ( + SELECT owner_uuid FROM workspaces WHERE uuid = g.workspace_uuid + ) + AND t.deleted_at IS NULL + LIMIT 1 +) +WHERE g.team_uuid IS NULL +AND g.deleted_at IS NULL; + +-- 设置 team_uuid 为 NOT NULL(如果还有 NULL 值,可能需要手动处理) +-- ALTER TABLE groups ALTER COLUMN team_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE groups +ADD CONSTRAINT fk_groups_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- ============================================ +-- 步骤 6: 更新 environments 表的 workspace_uuid +-- ============================================ +UPDATE environments e +SET workspace_uuid = t.workspace_uuid +FROM teams t +WHERE e.team_uuid = t.uuid + AND e.workspace_uuid IS NULL + AND e.deleted_at IS NULL; + +-- 对于没有 team_uuid 的环境,使用用户的个人工作空间 +UPDATE environments e +SET workspace_uuid = ( + SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = e.user_uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1 +) +WHERE e.workspace_uuid IS NULL + AND e.deleted_at IS NULL; + +-- 设置 workspace_uuid 为 NOT NULL +ALTER TABLE environments ALTER COLUMN workspace_uuid SET NOT NULL; + +-- 确保 team_uuid 为 NOT NULL(为没有 team_uuid 的环境分配默认团队) +UPDATE environments e +SET team_uuid = ( + SELECT t.uuid FROM teams t + INNER JOIN workspaces w ON t.workspace_uuid = w.uuid + WHERE w.uuid = e.workspace_uuid + AND t.deleted_at IS NULL + LIMIT 1 +) +WHERE e.team_uuid IS NULL + AND e.deleted_at IS NULL; + +-- 设置 team_uuid 为 NOT NULL(如果还有 NULL 值,可能需要手动处理) +-- ALTER TABLE environments ALTER COLUMN team_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE environments +ADD CONSTRAINT fk_environments_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- ============================================ +-- 步骤 7: 更新 proxies 表的 workspace_uuid 和 owner_uuid +-- ============================================ +-- 确保 owner_uuid 已填充(从 user_uuid 复制) +UPDATE proxies p +SET owner_uuid = p.user_uuid +WHERE p.owner_uuid IS NULL + AND p.deleted_at IS NULL; + +-- 为代理分配工作空间(优先使用团队的工作空间,如果没有则使用用户的个人工作空间) +UPDATE proxies p +SET workspace_uuid = COALESCE( + (SELECT t.workspace_uuid FROM teams t + WHERE t.uuid = ( + SELECT e.team_uuid FROM environments e + WHERE e.proxy_uuid = p.uuid + AND e.deleted_at IS NULL + LIMIT 1 + ) + AND t.deleted_at IS NULL + LIMIT 1), + (SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = p.owner_uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1) +) +WHERE p.workspace_uuid IS NULL + AND p.deleted_at IS NULL; + +-- 设置 workspace_uuid 和 owner_uuid 为 NOT NULL +ALTER TABLE proxies ALTER COLUMN workspace_uuid SET NOT NULL; +ALTER TABLE proxies ALTER COLUMN owner_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE proxies +ADD CONSTRAINT fk_proxies_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +ALTER TABLE proxies +ADD CONSTRAINT fk_proxies_owner +FOREIGN KEY (owner_uuid) REFERENCES users(uuid) ON DELETE CASCADE; + +-- 删除旧的 user_uuid 列(如果存在) +ALTER TABLE proxies DROP COLUMN IF EXISTS user_uuid; + +-- ============================================ +-- 步骤 8: 更新 subscriptions 表的 workspace_uuid +-- ============================================ +-- 为订阅分配工作空间(使用订阅者的个人工作空间) +UPDATE subscriptions s +SET workspace_uuid = ( + SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = s.user_uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1 +) +WHERE s.workspace_uuid IS NULL; + +-- 设置 workspace_uuid 为 NOT NULL +ALTER TABLE subscriptions ALTER COLUMN workspace_uuid SET NOT NULL; + +-- 添加外键约束 +ALTER TABLE subscriptions +ADD CONSTRAINT fk_subscriptions_workspace +FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- ============================================ +-- 步骤 9: 迁移配额数据从 user_quotas 到 workspace_quotas +-- ============================================ +INSERT INTO workspace_quotas ( + workspace_uuid, + max_environments, + used_environments, + max_team_members, + used_team_members, + max_proxies, + used_proxies, + max_rpa_tasks, + used_rpa_tasks, + created_at, + updated_at +) +SELECT + w.uuid, + COALESCE(uq.max_environments, 10), + COALESCE(uq.used_environments, 0), + COALESCE(uq.max_team_members, 5), + 0, -- used_team_members: user_quotas 表中没有此字段,使用默认值 0 + COALESCE(uq.max_proxies, 10), + COALESCE(uq.used_proxies, 0), + COALESCE(uq.max_rpa_tasks, 5), + COALESCE(uq.used_rpa_tasks, 0), + COALESCE(uq.created_at, CURRENT_TIMESTAMP), + COALESCE(uq.updated_at, CURRENT_TIMESTAMP) +FROM workspaces w +LEFT JOIN user_quotas uq ON w.owner_uuid = uq.user_uuid +WHERE w.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM workspace_quotas wq + WHERE wq.workspace_uuid = w.uuid + ); + +-- ============================================ +-- 步骤 10: 更新配额使用情况统计 +-- ============================================ +-- 更新环境使用数 +UPDATE workspace_quotas wq +SET used_environments = ( + SELECT COUNT(*) FROM environments e + WHERE e.workspace_uuid = wq.workspace_uuid + AND e.deleted_at IS NULL +); + +-- 更新代理使用数 +UPDATE workspace_quotas wq +SET used_proxies = ( + SELECT COUNT(*) FROM proxies p + WHERE p.workspace_uuid = wq.workspace_uuid + AND p.deleted_at IS NULL +); + +-- 更新团队成员使用数(所有团队总和) +UPDATE workspace_quotas wq +SET used_team_members = ( + SELECT COUNT(DISTINCT tm.user_uuid) FROM team_members tm + INNER JOIN teams t ON tm.team_uuid = t.uuid + WHERE t.workspace_uuid = wq.workspace_uuid + AND tm.deleted_at IS NULL + AND t.deleted_at IS NULL +); + +-- 更新 RPA 任务使用数(通过 team_uuid 关联到工作空间) +UPDATE workspace_quotas wq +SET used_rpa_tasks = ( + SELECT COUNT(*) FROM rpa_tasks rt + INNER JOIN teams t ON rt.team_uuid = t.uuid + WHERE t.workspace_uuid = wq.workspace_uuid + AND rt.deleted_at IS NULL + AND t.deleted_at IS NULL +) +WHERE EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = 'rpa_tasks' +); + +COMMIT; + +-- 迁移完成后的验证查询(可选,用于检查迁移结果) +-- SELECT +-- 'workspaces' as table_name, +-- COUNT(*) as total_count, +-- COUNT(*) FILTER (WHERE deleted_at IS NULL) as active_count +-- FROM workspaces +-- UNION ALL +-- SELECT +-- 'teams', +-- COUNT(*), +-- COUNT(*) FILTER (WHERE deleted_at IS NULL) +-- FROM teams +-- UNION ALL +-- SELECT +-- 'workspace_quotas', +-- COUNT(*), +-- COUNT(*) +-- FROM workspace_quotas; + diff --git a/server/migrations/20250125000013_alter_user_infos_add_current_workspace.sql b/server/migrations/20250125000013_alter_user_infos_add_current_workspace.sql new file mode 100644 index 00000000..9ccc7635 --- /dev/null +++ b/server/migrations/20250125000013_alter_user_infos_add_current_workspace.sql @@ -0,0 +1,44 @@ +-- 修改 user_infos 表,添加当前工作空间字段 +-- 用于工作空间切换功能 + +ALTER TABLE user_infos +ADD COLUMN IF NOT EXISTS current_workspace_uuid UUID; + +-- 添加外键约束(如果不存在) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_user_infos_current_workspace' + ) THEN + ALTER TABLE user_infos + ADD CONSTRAINT fk_user_infos_current_workspace + FOREIGN KEY (current_workspace_uuid) REFERENCES workspaces(uuid) ON DELETE SET NULL; + END IF; +END $$; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_user_infos_current_workspace ON user_infos(current_workspace_uuid); + +-- 从当前团队的工作空间初始化 current_workspace_uuid +UPDATE user_infos ui +SET current_workspace_uuid = ( + SELECT t.workspace_uuid FROM teams t + WHERE t.uuid = ui.current_team_uuid + AND t.deleted_at IS NULL + LIMIT 1 +) +WHERE ui.current_team_uuid IS NOT NULL + AND ui.current_workspace_uuid IS NULL; + +-- 对于没有当前团队的用户,使用其个人工作空间 +UPDATE user_infos ui +SET current_workspace_uuid = ( + SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = ui.user_uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1 +) +WHERE ui.current_workspace_uuid IS NULL; + diff --git a/server/migrations/20250126000001_insert_default_plans_with_features.sql b/server/migrations/20250126000001_insert_default_plans_with_features.sql new file mode 100644 index 00000000..e2366bb1 --- /dev/null +++ b/server/migrations/20250126000001_insert_default_plans_with_features.sql @@ -0,0 +1,432 @@ +-- 插入默认套餐数据和特性 +-- 删除现有套餐数据(包括关联的特性数据,因为有外键约束会自动删除) + +-- 1. 先删除 invoices 表中引用 subscriptions 的数据 +-- 由于 invoices 表有外键约束引用 subscriptions,需要先删除发票数据 +-- 注意:这会删除所有现有发票记录,请确保这是预期的行为 +DELETE FROM invoices; + +-- 2. 删除 subscriptions 表中引用 plans 的数据 +-- 由于 subscriptions 表有外键约束(没有 ON DELETE CASCADE),需要先删除订阅数据 +-- 注意:这会删除所有现有订阅记录,请确保这是预期的行为 +DELETE FROM subscriptions; + +-- 3. 删除现有套餐数据(会级联删除 plan_features 数据) +DELETE FROM plans; + +-- 4. 插入新的套餐数据 +-- 套餐:免费套餐、基础套餐、专业套餐、商业套餐、企业套餐 +-- 月付价格:0, 21.4, 11.4, 758.4, 3558.4 +-- 年付价格:按月付*10计算(相当于2个月免费) +-- 最大环境:8, 100, 1000, 10000, 100000 +-- 最大团队:1, 5, 10, 18, 30 +-- 代理数量:所有都是99999 +-- max_rpa_tasks:所有都是99999 + +INSERT INTO plans (uuid, name, description, price_per_month, price_per_year, currency, max_environments, max_team_members, max_proxies, max_rpa_tasks, is_recommended, sort_order, status) +VALUES + -- 免费套餐:月付 0,年付 0,环境 8,团队 1 + (gen_random_uuid(), '免费套餐', '适合个人试用,体验基础功能', 0.00, 0.00, 'USD', 8, 1, 99999, 99999, FALSE, 1, 'active'), + -- 基础套餐:月付 21.4,年付 214.00,环境 100,团队 5 + (gen_random_uuid(), '基础套餐', '适合个人用户和小型项目', 21.40, 214.00, 'USD', 100, 5, 99999, 99999, FALSE, 2, 'active'), + -- 专业套餐:月付 11.4,年付 114.00,环境 1000,团队 10 + (gen_random_uuid(), '专业套餐', '适合小型团队和成长型企业', 108.40, 1084.00, 'USD', 1000, 10, 99999, 99999, TRUE, 3, 'active'), + -- 商业套餐:月付 758.4,年付 7584.00,环境 10000,团队 18 + (gen_random_uuid(), '商业套餐', '适合中大型企业和专业团队', 758.40, 7584.00, 'USD', 10000, 18, 99999, 99999, FALSE, 4, 'active'), + -- 企业套餐:月付 3558.4,年付 35584.00,环境 100000,团队 30 + (gen_random_uuid(), '企业套餐', '适合大型企业,提供最高级别的服务和支持', 3558.40, 35584.00, 'USD', 100000, 30, 99999, 99999, FALSE, 5, 'active') +ON CONFLICT DO NOTHING; + +-- 5. 为每个套餐插入特性数据 +-- 使用 CTE 来获取套餐 UUID,然后插入特性 + +-- 免费套餐特性 +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'env_limit', + '环境数量', + '8 个环境', + TRUE, + 1 +FROM plans p WHERE p.name = '免费套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'team_members', + '团队成员', + '1 人', + TRUE, + 2 +FROM plans p WHERE p.name = '免费套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'basic_support', + '基础支持', + '社区支持', + TRUE, + 3 +FROM plans p WHERE p.name = '免费套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +-- 基础套餐特性 +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'env_limit', + '环境数量', + '100 个环境', + TRUE, + 1 +FROM plans p WHERE p.name = '基础套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'team_members', + '团队成员', + '5 人', + TRUE, + 2 +FROM plans p WHERE p.name = '基础套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'proxy_access', + '代理访问', + '99999 个代理', + TRUE, + 3 +FROM plans p WHERE p.name = '基础套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'rpa_tasks', + 'RPA 任务', + '99999 个任务', + TRUE, + 4 +FROM plans p WHERE p.name = '基础套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'email_support', + '邮件支持', + '工作日支持', + TRUE, + 5 +FROM plans p WHERE p.name = '基础套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +-- 专业套餐特性(包含基础套餐的所有特性,并增加更多) +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'env_limit', + '环境数量', + '1000 个环境', + TRUE, + 1 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'team_members', + '团队成员', + '10 人', + TRUE, + 2 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'proxy_access', + '代理访问', + '99999 个代理', + TRUE, + 3 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'rpa_tasks', + 'RPA 任务', + '99999 个任务', + TRUE, + 4 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'priority_support', + '优先支持', + '工作日优先响应', + TRUE, + 5 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'api_access', + 'API 访问', + '完整 API 权限', + TRUE, + 6 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'advanced_features', + '高级功能', + '指纹管理、Cookie 同步等', + TRUE, + 7 +FROM plans p WHERE p.name = '专业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +-- 商业套餐特性(包含专业套餐的所有特性,并增加更多) +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'env_limit', + '环境数量', + '10000 个环境', + TRUE, + 1 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'team_members', + '团队成员', + '18 人', + TRUE, + 2 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'proxy_access', + '代理访问', + '99999 个代理', + TRUE, + 3 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'rpa_tasks', + 'RPA 任务', + '99999 个任务', + TRUE, + 4 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'dedicated_support', + '专属支持', + '7x24 小时专属支持', + TRUE, + 5 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'api_access', + 'API 访问', + '完整 API 权限 + 高级 API', + TRUE, + 6 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'advanced_features', + '高级功能', + '所有高级功能', + TRUE, + 7 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'custom_integration', + '定制集成', + '支持定制化集成', + TRUE, + 8 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'sla_guarantee', + 'SLA 保障', + '99.9% 可用性保障', + TRUE, + 9 +FROM plans p WHERE p.name = '商业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +-- 企业套餐特性(包含商业套餐的所有特性,并增加更多) +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'env_limit', + '环境数量', + '100000 个环境', + TRUE, + 1 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'team_members', + '团队成员', + '30 人', + TRUE, + 2 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'proxy_access', + '代理访问', + '99999 个代理', + TRUE, + 3 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'rpa_tasks', + 'RPA 任务', + '99999 个任务', + TRUE, + 4 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'premium_support', + '高级支持', + '7x24 小时专属支持 + 专属客户经理', + TRUE, + 5 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'api_access', + 'API 访问', + '完整 API 权限 + 高级 API + 定制 API', + TRUE, + 6 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'advanced_features', + '高级功能', + '所有高级功能 + 企业级功能', + TRUE, + 7 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'custom_integration', + '定制集成', + '完全定制化集成 + 专属技术支持', + TRUE, + 8 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'sla_guarantee', + 'SLA 保障', + '99.99% 可用性保障', + TRUE, + 9 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'dedicated_infrastructure', + '专属基础设施', + '专属服务器和资源', + TRUE, + 10 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + +INSERT INTO plan_features (plan_uuid, feature_key, feature_name, feature_value, is_included, sort_order) +SELECT + p.uuid, + 'training_sessions', + '培训服务', + '定期培训和最佳实践指导', + TRUE, + 11 +FROM plans p WHERE p.name = '企业套餐' +ON CONFLICT (plan_uuid, feature_key) DO NOTHING; + diff --git a/server/migrations/20250126000002_create_user_coupons.sql b/server/migrations/20250126000002_create_user_coupons.sql new file mode 100644 index 00000000..24d65e5c --- /dev/null +++ b/server/migrations/20250126000002_create_user_coupons.sql @@ -0,0 +1,27 @@ +-- 创建 user_coupons 表 +-- 用户优惠券表 + +CREATE TABLE IF NOT EXISTS user_coupons ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL, + coupon_uuid UUID NOT NULL, + -- 状态: unused, used, expired + status VARCHAR(50) NOT NULL DEFAULT 'unused', + -- 发放时间 + issued_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 使用时间 + used_at TIMESTAMP WITH TIME ZONE, + -- 过期时间(可选,继承自优惠券或自定义) + expires_at TIMESTAMP WITH TIME ZONE, + -- 约束 + CONSTRAINT fk_user_coupons_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_user_coupons_coupon FOREIGN KEY (coupon_uuid) REFERENCES coupons(uuid), + -- 唯一约束:同一用户不能重复拥有同一优惠券 + UNIQUE(user_uuid, coupon_uuid) +); + +-- 创建索引 +CREATE INDEX idx_user_coupons_user_uuid ON user_coupons(user_uuid); +CREATE INDEX idx_user_coupons_status ON user_coupons(status); +CREATE INDEX idx_user_coupons_coupon_uuid ON user_coupons(coupon_uuid); +CREATE INDEX idx_user_coupons_expires_at ON user_coupons(expires_at); diff --git a/server/migrations/20250126000003_insert_default_coupons.sql b/server/migrations/20250126000003_insert_default_coupons.sql new file mode 100644 index 00000000..1b35f658 --- /dev/null +++ b/server/migrations/20250126000003_insert_default_coupons.sql @@ -0,0 +1,37 @@ +-- 插入默认优惠券数据 + +-- 1. 新用户专享优惠券(10%折扣,无使用次数限制,适用于所有订单) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'WELCOME10', 'percentage', 10.00, 0.00, NULL, NULL, 1, CURRENT_TIMESTAMP, NULL, 'all', 'active') +ON CONFLICT (code) DO NOTHING; + +-- 2. 年付优惠券(固定金额 $20,最低消费 $100,适用于订阅) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'YEARLY20', 'fixed', 20.00, 100.00, NULL, NULL, 1, CURRENT_TIMESTAMP, NULL, 'subscription', 'active') +ON CONFLICT (code) DO NOTHING; + +-- 3. 限时促销优惠券(15%折扣,最大折扣 $50,使用次数限制 100 次) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'PROMO15', 'percentage', 15.00, 50.00, 50.00, 100, 1, CURRENT_TIMESTAMP, (CURRENT_TIMESTAMP + INTERVAL '30 days'), 'all', 'active') +ON CONFLICT (code) DO NOTHING; + +-- 4. 大额订单优惠券(固定金额 $50,最低消费 $200) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'BIG50', 'fixed', 50.00, 200.00, NULL, NULL, 1, CURRENT_TIMESTAMP, NULL, 'all', 'active') +ON CONFLICT (code) DO NOTHING; + +-- 5. 年付专属优惠券(20%折扣,仅适用于年付订阅,最大折扣 $200) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'YEARLY20PCT', 'percentage', 20.00, 100.00, 200.00, NULL, 1, CURRENT_TIMESTAMP, NULL, 'subscription', 'active') +ON CONFLICT (code) DO NOTHING; + +-- 6. 首次购买优惠券(固定金额 $10,无最低消费,每人限用1次) +INSERT INTO coupons (uuid, code, discount_type, discount_value, min_amount, max_discount, max_uses, max_uses_per_user, valid_from, valid_until, applicable_to, status) +VALUES + (gen_random_uuid(), 'FIRST10', 'fixed', 10.00, 0.00, NULL, NULL, 1, CURRENT_TIMESTAMP, NULL, 'all', 'active') +ON CONFLICT (code) DO NOTHING; diff --git a/server/migrations/20250127000001_add_coupon_name_description.sql b/server/migrations/20250127000001_add_coupon_name_description.sql new file mode 100644 index 00000000..f8f7b989 --- /dev/null +++ b/server/migrations/20250127000001_add_coupon_name_description.sql @@ -0,0 +1,13 @@ +-- 为优惠券表添加名称和描述字段 + +ALTER TABLE coupons +ADD COLUMN IF NOT EXISTS name VARCHAR(100), +ADD COLUMN IF NOT EXISTS description TEXT; + +-- 为现有优惠券更新名称和描述 +UPDATE coupons SET name = '新用户专享', description = '新用户注册专享优惠,享受10%折扣' WHERE code = 'WELCOME10'; +UPDATE coupons SET name = '年付优惠', description = '年付订阅专享,立减$20,最低消费$100' WHERE code = 'YEARLY20'; +UPDATE coupons SET name = '限时促销', description = '限时促销活动,享受15%折扣,最大折扣$50' WHERE code = 'PROMO15'; +UPDATE coupons SET name = '大额订单优惠', description = '大额订单专享,立减$50,最低消费$200' WHERE code = 'BIG50'; +UPDATE coupons SET name = '年付专属', description = '年付订阅专属优惠,享受20%折扣,最大折扣$200' WHERE code = 'YEARLY20PCT'; +UPDATE coupons SET name = '首次购买', description = '首次购买专享,立减$10,无最低消费限制' WHERE code = 'FIRST10'; diff --git a/server/migrations/20250127000002_reset_referral_default_tiers.sql b/server/migrations/20250127000002_reset_referral_default_tiers.sql new file mode 100644 index 00000000..eeb7fcc9 --- /dev/null +++ b/server/migrations/20250127000002_reset_referral_default_tiers.sql @@ -0,0 +1,25 @@ +-- 重置推广链接层级默认数据 +-- 说明: +-- - 该迁移用于在开发 / 测试环境中统一推广层级配置 +-- - 按产品设计保留 4 个层级,按照邀请人数逐级解锁 +-- - 为避免脏数据干扰,这里会清空依赖 referral_link_tiers 的相关表 + +-- 1. 清空依赖表(注意:这会删除所有历史推广数据) +TRUNCATE TABLE redeem_records RESTART IDENTITY CASCADE; +TRUNCATE TABLE referral_rewards RESTART IDENTITY CASCADE; +TRUNCATE TABLE user_referrals RESTART IDENTITY CASCADE; +TRUNCATE TABLE referral_links RESTART IDENTITY CASCADE; +TRUNCATE TABLE referral_link_tiers RESTART IDENTITY CASCADE; + +-- 2. 重新插入 4 个默认推广层级 +INSERT INTO referral_link_tiers (uuid, name, unlock_threshold, reward_rate, discount_rate, description, sort_order) +VALUES + -- 第一档:默认开放 + (gen_random_uuid(), '青铜', 0, 10.00, 5.00, '默认层级,绑定第一个推广链接', 1), + -- 第二档:达到一定推广人数后解锁 + (gen_random_uuid(), '白银', 10, 15.00, 8.00, '推广满 10 人后解锁第二个推广链接', 2), + -- 第三档 + (gen_random_uuid(), '黄金', 50, 20.00, 10.00, '推广满 50 人后解锁第三个推广链接', 3), + -- 第四档 + (gen_random_uuid(), '钻石', 100, 25.00, 15.00, '推广满 100 人后解锁第四个推广链接', 4); + diff --git a/server/migrations/20250127000003_create_machine_tags_tables.sql b/server/migrations/20250127000003_create_machine_tags_tables.sql new file mode 100644 index 00000000..0ff0f807 --- /dev/null +++ b/server/migrations/20250127000003_create_machine_tags_tables.sql @@ -0,0 +1,53 @@ +-- Add migration script here +-- Create machine_tags and machine_user_tags tables for tag management + +-- Drop tables (for development) +-- DROP TABLE public.machine_user_tags CASCADE; +-- DROP TABLE public.machine_tags CASCADE; + +-- Create machine_tags table +CREATE TABLE public.machine_tags ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + tag_name varchar(100) NOT NULL, -- 标签名称 + description text NULL, -- 标签描述 + category varchar(50) NULL, -- 标签分类 + is_active boolean DEFAULT true NOT NULL, -- 是否激活 + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + CONSTRAINT machine_tags_pk PRIMARY KEY (id), + CONSTRAINT machine_tags_tag_name_unique UNIQUE (tag_name) +); + +-- Create machine_user_tags table (association table) +CREATE TABLE public.machine_user_tags ( + id int4 GENERATED BY DEFAULT AS IDENTITY( INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START 1 CACHE 1 NO CYCLE) NOT NULL, + machine_user_id int4 NOT NULL, -- 机器用户ID + tag_id int4 NOT NULL, -- 标签ID + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, -- 创建时间 + CONSTRAINT machine_user_tags_pk PRIMARY KEY (id), + CONSTRAINT machine_user_tags_machine_user_id_fk FOREIGN KEY (machine_user_id) REFERENCES public.machine_users(id) ON DELETE CASCADE, + CONSTRAINT machine_user_tags_tag_id_fk FOREIGN KEY (tag_id) REFERENCES public.machine_tags(id) ON DELETE CASCADE, + CONSTRAINT machine_user_tags_unique UNIQUE (machine_user_id, tag_id) +); + +-- Create indexes for performance +CREATE INDEX machine_tags_tag_name_idx ON public.machine_tags USING btree (tag_name); +CREATE INDEX machine_tags_is_active_idx ON public.machine_tags USING btree (is_active); +CREATE INDEX machine_user_tags_machine_user_id_idx ON public.machine_user_tags USING btree (machine_user_id); +CREATE INDEX machine_user_tags_tag_id_idx ON public.machine_user_tags USING btree (tag_id); + +-- Column comments +COMMENT ON TABLE public.machine_tags IS '机器标签主表'; +COMMENT ON TABLE public.machine_user_tags IS '机器用户标签关联表'; + +COMMENT ON COLUMN public.machine_tags.id IS '主键ID'; +COMMENT ON COLUMN public.machine_tags.tag_name IS '标签名称(唯一)'; +COMMENT ON COLUMN public.machine_tags.description IS '标签描述'; +COMMENT ON COLUMN public.machine_tags.category IS '标签分类'; +COMMENT ON COLUMN public.machine_tags.is_active IS '是否激活'; +COMMENT ON COLUMN public.machine_tags.created_at IS '创建时间'; + +COMMENT ON COLUMN public.machine_user_tags.id IS '主键ID'; +COMMENT ON COLUMN public.machine_user_tags.machine_user_id IS '机器用户ID'; +COMMENT ON COLUMN public.machine_user_tags.tag_id IS '标签ID'; +COMMENT ON COLUMN public.machine_user_tags.created_at IS '创建时间'; + diff --git a/server/migrations/20250204000001_alter_version_types_add_is_auto_download.sql b/server/migrations/20250204000001_alter_version_types_add_is_auto_download.sql new file mode 100644 index 00000000..e91548fc --- /dev/null +++ b/server/migrations/20250204000001_alter_version_types_add_is_auto_download.sql @@ -0,0 +1,7 @@ +-- Add is_auto_download flag to version_types to control participation in auto-update flows + +ALTER TABLE public.version_types +ADD COLUMN IF NOT EXISTS is_auto_download bool DEFAULT true NOT NULL; + +COMMENT ON COLUMN public.version_types.is_auto_download IS '是否参与自动检查更新与自动下载'; + diff --git a/server/migrations/20250204000002_alter_versions_add_kernel_fields.sql b/server/migrations/20250204000002_alter_versions_add_kernel_fields.sql new file mode 100644 index 00000000..a1059ef3 --- /dev/null +++ b/server/migrations/20250204000002_alter_versions_add_kernel_fields.sql @@ -0,0 +1,16 @@ +-- Extend versions table with kernel-related metadata fields. +-- These fields are optional for普通资源,但对浏览器内核版本会被要求填充。 + +ALTER TABLE public.versions +ADD COLUMN IF NOT EXISTS arch text NULL, +ADD COLUMN IF NOT EXISTS package_format text NULL, +ADD COLUMN IF NOT EXISTS requires_extract bool DEFAULT false NOT NULL, +ADD COLUMN IF NOT EXISTS entrypoint_template text NULL, +ADD COLUMN IF NOT EXISTS extract_root text NULL; + +COMMENT ON COLUMN public.versions.arch IS '架构(如 x86_64、arm64)'; +COMMENT ON COLUMN public.versions.package_format IS '包格式(如 zip、exe)'; +COMMENT ON COLUMN public.versions.requires_extract IS '是否需要解压(zip 等资源为 true)'; +COMMENT ON COLUMN public.versions.entrypoint_template IS '入口相对路径模板(如 Simprint-Browser/chrome-{version}/chrome.exe)'; +COMMENT ON COLUMN public.versions.extract_root IS '可选的解压根目录描述'; + diff --git a/server/migrations/20250204000003_insert_default_version_types.sql b/server/migrations/20250204000003_insert_default_version_types.sql new file mode 100644 index 00000000..d999c95a --- /dev/null +++ b/server/migrations/20250204000003_insert_default_version_types.sql @@ -0,0 +1,8 @@ +-- 初始化版本类型默认数据 +-- 注意:使用 ON CONFLICT 以保证迁移可重复执行 + +INSERT INTO public.version_types (type_code, type_name, description, sort_order, is_active, is_auto_download) +VALUES + ('SIMPRINT_CLIENT_INSTALLER', 'Simprint 客户端安装包', 'Simprint 浏览器主程序安装包', 10, true, true), + ('SIMPRINT_KERNEL_CHROMIUM', 'Chromium 内核', 'Chromium 浏览器内核 zip 包', 30, true, false) +ON CONFLICT (type_code) DO NOTHING; \ No newline at end of file diff --git a/server/migrations/20260213000001_add_updated_at_to_user_coupons.sql b/server/migrations/20260213000001_add_updated_at_to_user_coupons.sql new file mode 100644 index 00000000..a97141ce --- /dev/null +++ b/server/migrations/20260213000001_add_updated_at_to_user_coupons.sql @@ -0,0 +1,9 @@ +-- 为 user_coupons 表添加 updated_at 字段 +-- 修复订阅功能中缺少 updated_at 字段的问题 + +ALTER TABLE user_coupons +ADD COLUMN updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- 创建更新时间触发器 +CREATE TRIGGER update_user_coupons_updated_at BEFORE UPDATE ON user_coupons + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/server/migrations/20260306000001_fix_extensions_status_comments.sql b/server/migrations/20260306000001_fix_extensions_status_comments.sql new file mode 100644 index 00000000..c46f4779 --- /dev/null +++ b/server/migrations/20260306000001_fix_extensions_status_comments.sql @@ -0,0 +1,18 @@ +-- 修正扩展表的状态默认值和注释 +-- 将状态从 "installed, disabled" 改为 "active, inactive" 以对齐代码实现 + +-- user_extensions 表 +ALTER TABLE user_extensions ALTER COLUMN status SET DEFAULT 'active'; +COMMENT ON COLUMN user_extensions.status IS '状态: active, inactive'; + +-- team_extensions 表 +ALTER TABLE team_extensions ALTER COLUMN status SET DEFAULT 'active'; +COMMENT ON COLUMN team_extensions.status IS '状态: active, inactive'; + +-- group_extensions 表 +ALTER TABLE group_extensions ALTER COLUMN status SET DEFAULT 'active'; +COMMENT ON COLUMN group_extensions.status IS '状态: active, inactive'; + +-- environment_extensions 表 +ALTER TABLE environment_extensions ALTER COLUMN status SET DEFAULT 'active'; +COMMENT ON COLUMN environment_extensions.status IS '状态: active, inactive'; diff --git a/server/migrations/20260306000002_drop_environment_extensions.sql b/server/migrations/20260306000002_drop_environment_extensions.sql new file mode 100644 index 00000000..af9dfb16 --- /dev/null +++ b/server/migrations/20260306000002_drop_environment_extensions.sql @@ -0,0 +1,12 @@ +-- 删除 environment_extensions 表 +-- 环境不直接绑定插件,插件通过用户/团队/分组间接作用于环境 + +-- 删除触发器 +DROP TRIGGER IF EXISTS update_environment_extensions_updated_at ON environment_extensions; + +-- 删除索引 +DROP INDEX IF EXISTS idx_env_extensions_extension_id; +DROP INDEX IF EXISTS idx_env_extensions_env_uuid; + +-- 删除表 +DROP TABLE IF EXISTS environment_extensions; diff --git a/server/migrations/20260306000003_add_is_team_shared_to_group_extensions.sql b/server/migrations/20260306000003_add_is_team_shared_to_group_extensions.sql new file mode 100644 index 00000000..286a7b56 --- /dev/null +++ b/server/migrations/20260306000003_add_is_team_shared_to_group_extensions.sql @@ -0,0 +1,11 @@ +-- 为 group_extensions 表添加 is_team_shared 字段 +-- 用于区分"分组团队插件"和"分组个人插件" + +-- 添加 is_team_shared 字段 +ALTER TABLE group_extensions ADD COLUMN is_team_shared BOOLEAN NOT NULL DEFAULT false; + +-- 添加索引 +CREATE INDEX idx_group_extensions_is_team_shared ON group_extensions(is_team_shared); + +-- 更新注释 +COMMENT ON COLUMN group_extensions.is_team_shared IS '是否为团队共享: true=团队所有成员可用, false=仅创建者可用'; diff --git a/server/migrations/20260306000004_update_extension_status_support_disabled.sql b/server/migrations/20260306000004_update_extension_status_support_disabled.sql new file mode 100644 index 00000000..299781e1 --- /dev/null +++ b/server/migrations/20260306000004_update_extension_status_support_disabled.sql @@ -0,0 +1,7 @@ +-- 更新扩展状态支持 disabled +-- 允许用户禁用团队/分组插件 + +-- 更新状态注释 +COMMENT ON COLUMN user_extensions.status IS '状态: active=已启用, disabled=已禁用, inactive=已卸载'; +COMMENT ON COLUMN team_extensions.status IS '状态: active=已安装, inactive=已卸载'; +COMMENT ON COLUMN group_extensions.status IS '状态: active=已安装, inactive=已卸载'; diff --git a/server/migrations/20260306000005_create_user_team_extension_preferences.sql b/server/migrations/20260306000005_create_user_team_extension_preferences.sql new file mode 100644 index 00000000..badb26ce --- /dev/null +++ b/server/migrations/20260306000005_create_user_team_extension_preferences.sql @@ -0,0 +1,23 @@ +-- 创建用户团队插件偏好设置表 +CREATE TABLE IF NOT EXISTS user_team_extension_preferences ( + id SERIAL PRIMARY KEY, + user_uuid UUID NOT NULL, + team_uuid UUID NOT NULL, + extension_id VARCHAR(255) NOT NULL, + is_disabled BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_uuid, team_uuid, extension_id) +); + +-- 添加索引 +CREATE INDEX idx_user_team_extension_preferences_user_uuid ON user_team_extension_preferences(user_uuid); +CREATE INDEX idx_user_team_extension_preferences_team_uuid ON user_team_extension_preferences(team_uuid); +CREATE INDEX idx_user_team_extension_preferences_extension_id ON user_team_extension_preferences(extension_id); + +-- 添加注释 +COMMENT ON TABLE user_team_extension_preferences IS '用户对团队插件的偏好设置(如禁用)'; +COMMENT ON COLUMN user_team_extension_preferences.user_uuid IS '用户 UUID'; +COMMENT ON COLUMN user_team_extension_preferences.team_uuid IS '团队 UUID'; +COMMENT ON COLUMN user_team_extension_preferences.extension_id IS '插件 ID'; +COMMENT ON COLUMN user_team_extension_preferences.is_disabled IS '是否禁用(true 表示用户禁用了该团队插件)'; diff --git a/server/migrations/20260308000001_add_hash_to_extensions.sql b/server/migrations/20260308000001_add_hash_to_extensions.sql new file mode 100644 index 00000000..bd2db849 --- /dev/null +++ b/server/migrations/20260308000001_add_hash_to_extensions.sql @@ -0,0 +1,8 @@ +-- 为 extensions 表添加 hash 字段 +-- 用于存储扩展文件的哈希值 + +ALTER TABLE extensions +ADD COLUMN hash VARCHAR(255) DEFAULT NULL; + +-- 为 hash 字段创建索引(可选,如果需要通过 hash 查询) +CREATE INDEX idx_extensions_hash ON extensions(hash); diff --git a/server/migrations/20260314000001_alter_versions_require_publish_fields.sql b/server/migrations/20260314000001_alter_versions_require_publish_fields.sql new file mode 100644 index 00000000..de062ec4 --- /dev/null +++ b/server/migrations/20260314000001_alter_versions_require_publish_fields.sql @@ -0,0 +1,15 @@ +-- Enforce required publish metadata for versions exposed to the updater. +-- Existing dirty rows are removed first so the NOT NULL constraints can be applied safely. + +DELETE FROM public.versions +WHERE platform IS NULL + OR url IS NULL + OR hash IS NULL + OR file_size IS NULL + OR pub_date IS NULL; + +ALTER TABLE public.versions +ALTER COLUMN platform SET NOT NULL, +ALTER COLUMN url SET NOT NULL, +ALTER COLUMN hash SET NOT NULL, +ALTER COLUMN file_size SET NOT NULL; \ No newline at end of file diff --git a/server/migrations/20260316000001_create_local_api_service_tables.sql b/server/migrations/20260316000001_create_local_api_service_tables.sql new file mode 100644 index 00000000..71b4fd9a --- /dev/null +++ b/server/migrations/20260316000001_create_local_api_service_tables.sql @@ -0,0 +1,109 @@ +-- 创建本地 API 服务相关表 + +CREATE TABLE IF NOT EXISTS user_local_api_settings ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL UNIQUE, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + port INTEGER NOT NULL DEFAULT 8080, + remote_access BOOLEAN NOT NULL DEFAULT FALSE, + cors_origins JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT fk_user_local_api_settings_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT chk_user_local_api_settings_port CHECK (port BETWEEN 1 AND 65535), + CONSTRAINT chk_user_local_api_settings_cors_origins CHECK (jsonb_typeof(cors_origins) = 'array') +); + +CREATE INDEX idx_user_local_api_settings_user_uuid + ON user_local_api_settings(user_uuid); + +CREATE TRIGGER update_user_local_api_settings_updated_at + BEFORE UPDATE ON user_local_api_settings + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE IF NOT EXISTS user_local_api_keys ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_uuid UUID NOT NULL, + key_prefix VARCHAR(32) NOT NULL, + key_hash VARCHAR(128) NOT NULL, + masked_key VARCHAR(64) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + requests_today INTEGER NOT NULL DEFAULT 0, + daily_limit INTEGER NOT NULL DEFAULT 1000, + last_reset_date DATE NOT NULL DEFAULT CURRENT_DATE, + last_used_at TIMESTAMP WITH TIME ZONE, + expires_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT fk_user_local_api_keys_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT chk_user_local_api_keys_requests_today CHECK (requests_today >= 0), + CONSTRAINT chk_user_local_api_keys_daily_limit CHECK (daily_limit >= 0) +); + +CREATE INDEX idx_user_local_api_keys_user_uuid + ON user_local_api_keys(user_uuid); + +CREATE INDEX idx_user_local_api_keys_key_prefix + ON user_local_api_keys(key_prefix); + +CREATE INDEX idx_user_local_api_keys_key_hash + ON user_local_api_keys(key_hash); + +CREATE UNIQUE INDEX idx_user_local_api_keys_active_user + ON user_local_api_keys(user_uuid) + WHERE is_active = TRUE AND deleted_at IS NULL; + +CREATE TRIGGER update_user_local_api_keys_updated_at + BEFORE UPDATE ON user_local_api_keys + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE IF NOT EXISTS user_local_api_key_permissions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + api_key_id INTEGER NOT NULL, + permission_code VARCHAR(128) NOT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + rate_limit_per_minute INTEGER NOT NULL DEFAULT 60, + rate_limit_per_hour INTEGER NOT NULL DEFAULT 1000, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT fk_user_local_api_key_permissions_key FOREIGN KEY (api_key_id) REFERENCES user_local_api_keys(id) ON DELETE CASCADE, + CONSTRAINT uq_user_local_api_key_permissions UNIQUE (api_key_id, permission_code), + CONSTRAINT chk_user_local_api_key_permissions_minute CHECK (rate_limit_per_minute >= 0), + CONSTRAINT chk_user_local_api_key_permissions_hour CHECK (rate_limit_per_hour >= 0) +); + +CREATE INDEX idx_user_local_api_key_permissions_key + ON user_local_api_key_permissions(api_key_id); + +CREATE INDEX idx_user_local_api_key_permissions_code + ON user_local_api_key_permissions(permission_code); + +CREATE TRIGGER update_user_local_api_key_permissions_updated_at + BEFORE UPDATE ON user_local_api_key_permissions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE IF NOT EXISTS user_local_api_request_counters ( + id SERIAL PRIMARY KEY, + api_key_id INTEGER NOT NULL, + permission_code VARCHAR(128) NOT NULL, + window_type VARCHAR(16) NOT NULL, + window_start TIMESTAMP WITH TIME ZONE NOT NULL, + request_count INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_user_local_api_request_counters_key FOREIGN KEY (api_key_id) REFERENCES user_local_api_keys(id) ON DELETE CASCADE, + CONSTRAINT uq_user_local_api_request_counters UNIQUE (api_key_id, permission_code, window_type, window_start), + CONSTRAINT chk_user_local_api_request_counters_window_type CHECK (window_type IN ('minute', 'hour', 'day')), + CONSTRAINT chk_user_local_api_request_counters_request_count CHECK (request_count >= 0) +); + +CREATE INDEX idx_user_local_api_request_counters_key + ON user_local_api_request_counters(api_key_id); + +CREATE INDEX idx_user_local_api_request_counters_lookup + ON user_local_api_request_counters(api_key_id, permission_code, window_type, window_start); diff --git a/server/migrations/20260316000002_initialize_local_api_default_configs.sql b/server/migrations/20260316000002_initialize_local_api_default_configs.sql new file mode 100644 index 00000000..c97f8ee2 --- /dev/null +++ b/server/migrations/20260316000002_initialize_local_api_default_configs.sql @@ -0,0 +1,13 @@ +-- 初始化本地 API 功能默认配置 +-- 这些配置作为系统级默认值存在,具体用户数据仍在首次使用时按需创建。 + +INSERT INTO system_configs (config_key, config_value, description) +VALUES + ('local_api_default_enabled', 'false', '本地 API 默认是否启用'), + ('local_api_default_port', '8080', '本地 API 默认监听端口'), + ('local_api_default_remote_access', 'false', '本地 API 默认是否允许局域网访问'), + ('local_api_default_cors_origins', '[]', '本地 API 默认允许的 CORS 来源'), + ('local_api_default_daily_limit', '1000', '本地 API 默认每日总调用上限'), + ('local_api_default_rate_limit_per_minute', '60', '本地 API 默认每分钟限流'), + ('local_api_default_rate_limit_per_hour', '1000', '本地 API 默认每小时限流') +ON CONFLICT (config_key) DO NOTHING; diff --git a/server/migrations/20260316000003_create_local_api_permission_definitions.sql b/server/migrations/20260316000003_create_local_api_permission_definitions.sql new file mode 100644 index 00000000..2c9172ad --- /dev/null +++ b/server/migrations/20260316000003_create_local_api_permission_definitions.sql @@ -0,0 +1,25 @@ +-- 创建本地 API 权限定义表 + +CREATE TABLE IF NOT EXISTS local_api_permission_definitions ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + permission_code VARCHAR(128) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + description TEXT, + default_enabled BOOLEAN NOT NULL DEFAULT TRUE, + default_rate_limit_per_minute INTEGER NOT NULL DEFAULT 60, + default_rate_limit_per_hour INTEGER NOT NULL DEFAULT 1000, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT chk_local_api_permission_definitions_minute CHECK (default_rate_limit_per_minute >= 0), + CONSTRAINT chk_local_api_permission_definitions_hour CHECK (default_rate_limit_per_hour >= 0) +); + +CREATE INDEX idx_local_api_permission_definitions_sort_order + ON local_api_permission_definitions(sort_order); + +CREATE TRIGGER update_local_api_permission_definitions_updated_at + BEFORE UPDATE ON local_api_permission_definitions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/server/migrations/20260316000004_initialize_local_api_permission_definitions.sql b/server/migrations/20260316000004_initialize_local_api_permission_definitions.sql new file mode 100644 index 00000000..0694f031 --- /dev/null +++ b/server/migrations/20260316000004_initialize_local_api_permission_definitions.sql @@ -0,0 +1,55 @@ +-- 初始化本地 API 权限定义 + +INSERT INTO local_api_permission_definitions ( + permission_code, name, description, default_enabled, default_rate_limit_per_minute, default_rate_limit_per_hour, sort_order +) +VALUES + ('workspaces.list', '工作空间列表', '读取工作空间列表', TRUE, 120, 3000, 10), + ('workspaces.get', '工作空间详情', '读取工作空间详情', TRUE, 120, 3000, 20), + ('workspaces.switch', '切换工作空间', '切换当前工作空间', TRUE, 60, 1000, 30), + ('browser-kernels.list', '浏览器内核列表', '读取浏览器内核列表', TRUE, 120, 3000, 40), + ('groups.list', '分组列表', '读取分组列表', TRUE, 120, 3000, 50), + ('groups.create', '创建分组', '创建新的分组', TRUE, 30, 500, 60), + ('groups.update', '更新分组', '更新分组信息', TRUE, 30, 500, 70), + ('groups.delete', '删除分组', '删除分组', TRUE, 20, 300, 80), + ('tags.list', '标签列表', '读取标签列表', TRUE, 120, 3000, 90), + ('tags.create', '创建标签', '创建新的标签', TRUE, 30, 500, 100), + ('tags.update', '更新标签', '更新标签信息', TRUE, 30, 500, 110), + ('tags.delete', '删除标签', '删除标签', TRUE, 20, 300, 120), + ('environments.list', '环境列表', '读取环境列表', TRUE, 120, 3000, 130), + ('environments.detail', '环境详情', '读取环境详情', TRUE, 120, 3000, 140), + ('environments.batch-detail', '批量环境详情', '批量读取环境详情', TRUE, 60, 1200, 150), + ('environments.create', '创建环境', '创建新的环境', TRUE, 20, 300, 160), + ('environments.batch-create', '批量创建环境', '批量创建环境', TRUE, 10, 120, 170), + ('environments.update', '更新环境', '更新环境配置', TRUE, 30, 500, 180), + ('environments.delete', '删除环境', '删除环境', TRUE, 20, 300, 190), + ('environments.batch-delete', '批量删除环境', '批量删除环境', TRUE, 10, 120, 200), + ('environments.set-proxy', '设置环境代理', '为环境设置代理', TRUE, 30, 500, 210), + ('environments.assign-tags', '分配标签', '为环境分配标签', TRUE, 30, 500, 220), + ('environments.remove-tag', '移除标签', '从环境移除标签', TRUE, 30, 500, 230), + ('environments.move-to-group', '移动到分组', '将环境移动到指定分组', TRUE, 30, 500, 240), + ('environments.batch-move-to-group', '批量移动到分组', '批量移动环境到指定分组', TRUE, 15, 180, 250), + ('environments.set-accounts', '设置环境账号', '为环境关联账号', TRUE, 20, 300, 260), + ('environments.batch-assign-tags', '批量分配标签', '批量为环境分配标签', TRUE, 15, 180, 270), + ('environments.batch-remove-tags', '批量移除标签', '批量从环境移除标签', TRUE, 15, 180, 280), + ('environments.urls.list', '环境 URL 列表', '读取环境 URL 列表', TRUE, 120, 3000, 290), + ('environments.urls.add', '添加环境 URL', '向环境添加 URL', TRUE, 30, 500, 300), + ('environments.urls.delete', '删除环境 URL', '删除环境 URL', TRUE, 30, 500, 310), + ('environments.urls.clear', '清空环境 URL', '清空环境 URL', TRUE, 20, 300, 320), + ('environments.cookies.list', '环境 Cookie 列表', '读取环境 Cookie 列表', TRUE, 120, 3000, 330), + ('environments.cookies.add', '添加环境 Cookie', '向环境添加 Cookie', TRUE, 30, 500, 340), + ('environments.cookies.delete', '删除环境 Cookie', '删除环境 Cookie', TRUE, 30, 500, 350), + ('environments.cookies.clear', '清空环境 Cookie', '清空环境 Cookie', TRUE, 20, 300, 360), + ('environments.recycle-bin.list', '回收站环境列表', '读取回收站中的环境', TRUE, 120, 3000, 370), + ('environments.recycle-bin.restore', '恢复环境', '从回收站恢复环境', TRUE, 20, 300, 380), + ('environments.recycle-bin.batch-restore', '批量恢复环境', '批量从回收站恢复环境', TRUE, 10, 120, 390), + ('environments.recycle-bin.permanent-delete', '永久删除环境', '永久删除环境', TRUE, 10, 120, 400), + ('environments.recycle-bin.batch-permanent-delete', '批量永久删除环境', '批量永久删除环境', TRUE, 5, 60, 410), + ('proxies.list', '代理列表', '读取代理列表', TRUE, 120, 3000, 420), + ('proxies.detail', '代理详情', '读取代理详情', TRUE, 120, 3000, 430), + ('proxies.create', '创建代理', '创建新的代理', TRUE, 30, 500, 440), + ('proxies.update', '更新代理', '更新代理信息', TRUE, 30, 500, 450), + ('proxies.delete', '删除代理', '删除代理', TRUE, 20, 300, 460), + ('proxies.batch-delete', '批量删除代理', '批量删除代理', TRUE, 10, 120, 470), + ('proxies.batch-import', '批量导入代理', '批量导入代理', TRUE, 10, 120, 480) +ON CONFLICT (permission_code) DO NOTHING; diff --git a/server/migrations/20260316000005_replace_masked_key_with_api_key.sql b/server/migrations/20260316000005_replace_masked_key_with_api_key.sql new file mode 100644 index 00000000..8bf5b659 --- /dev/null +++ b/server/migrations/20260316000005_replace_masked_key_with_api_key.sql @@ -0,0 +1,9 @@ +-- 使用完整 api_key 替代 masked_key +-- 旧数据无法从 masked_key 还原明文,因此 api_key 先允许为空; +-- 服务端在发现历史记录缺少 api_key 时,会自动轮换生成新 key。 + +ALTER TABLE user_local_api_keys + ADD COLUMN IF NOT EXISTS api_key TEXT; + +ALTER TABLE user_local_api_keys + DROP COLUMN IF EXISTS masked_key; diff --git a/server/migrations/20260506000001_replace_encrypted_password_columns.sql b/server/migrations/20260506000001_replace_encrypted_password_columns.sql new file mode 100644 index 00000000..62dea714 --- /dev/null +++ b/server/migrations/20260506000001_replace_encrypted_password_columns.sql @@ -0,0 +1,11 @@ +ALTER TABLE public.proxies + DROP COLUMN IF EXISTS password_encrypted, + ADD COLUMN password TEXT; + +COMMENT ON COLUMN public.proxies.password IS '代理密码(明文存储)'; + +ALTER TABLE public.platform_accounts + DROP COLUMN IF EXISTS password_encrypted, + ADD COLUMN password TEXT; + +COMMENT ON COLUMN public.platform_accounts.password IS '平台账号密码(明文存储)'; diff --git a/server/migrations/20260506000002_drop_machine_and_gray_tables.sql b/server/migrations/20260506000002_drop_machine_and_gray_tables.sql new file mode 100644 index 00000000..9bdf5086 --- /dev/null +++ b/server/migrations/20260506000002_drop_machine_and_gray_tables.sql @@ -0,0 +1,6 @@ +DROP TABLE IF EXISTS public.machine_gray_allocations; +DROP TABLE IF EXISTS public.gray_resources; +DROP TABLE IF EXISTS public.gray_releases; +DROP TABLE IF EXISTS public.machine_user_tags; +DROP TABLE IF EXISTS public.machine_tags; +DROP TABLE IF EXISTS public.machine_users; diff --git a/server/migrations/20260508000001_insert_default_browser_kernel_version.sql b/server/migrations/20260508000001_insert_default_browser_kernel_version.sql new file mode 100644 index 00000000..7c6125e6 --- /dev/null +++ b/server/migrations/20260508000001_insert_default_browser_kernel_version.sql @@ -0,0 +1,67 @@ +-- 初始化默认浏览器内核版本数据 +-- 使用 version_types.type_code 定位类型,避免写死 type_id。 +-- 使用 NOT EXISTS 保证迁移可重复执行。 + +WITH kernel_type AS ( + SELECT id + FROM public.version_types + WHERE type_code = 'SIMPRINT_KERNEL_CHROMIUM' +) +INSERT INTO public.versions ( + type_id, + resource_name, + version, + name, + notes, + platform, + url, + hash, + signature, + install_path, + file_size, + is_latest, + status, + pub_date, + created_at, + updated_at, + deleted_at, + arch, + package_format, + requires_extract, + entrypoint_template, + extract_root +) +SELECT + kernel_type.id, + 'Chrome 144', + '144.0.7559.118.1', + 'simprint-browser-144.0.7559.118.zip', + '上传浏览器内核版本。', + 'windows', + 'versions/144.0.7559.118.1/simprint-browser-144.0.7559.118.zip', + 'c74ce58537c93e99e4099c94667a21a8e150ac4052c383bc2a095ffdb3b0e075', + 'a864f950c2e77d18ef932f2ad3dbde63039c3b467994daab83081ce6b28c4f82', + NULL, + 184526645, + false, + 'active', + '2026-05-05 17:01:26.746804+08'::timestamptz, + '2026-05-05 17:01:26.746804+08'::timestamptz, + NULL, + NULL, + 'x86_64', + 'zip', + true, + NULL, + NULL +FROM kernel_type +WHERE NOT EXISTS ( + SELECT 1 + FROM public.versions v + WHERE v.type_id = kernel_type.id + AND v.resource_name = 'Chrome 144' + AND v.version = '144.0.7559.118.1' + AND v.platform = 'windows' + AND v.arch = 'x86_64' + AND v.deleted_at IS NULL +); diff --git a/server/migrations/20260511000015_add_site_input_to_environment_cookies.sql b/server/migrations/20260511000015_add_site_input_to_environment_cookies.sql new file mode 100644 index 00000000..7a6c538e --- /dev/null +++ b/server/migrations/20260511000015_add_site_input_to_environment_cookies.sql @@ -0,0 +1,5 @@ +ALTER TABLE environment_cookies +ADD COLUMN IF NOT EXISTS site_input TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_env_cookies_env_uuid_site_input +ON environment_cookies(environment_uuid, site_input); diff --git a/server/rustfmt.toml b/server/rustfmt.toml new file mode 100644 index 00000000..e79c5a6f --- /dev/null +++ b/server/rustfmt.toml @@ -0,0 +1,17 @@ +# rustfmt 配置文件 +# 注意:某些配置选项需要 nightly 版本,这里只使用稳定版本支持的选项 +edition = "2021" +max_width = 100 +tab_spaces = 4 +newline_style = "Unix" +use_small_heuristics = "Default" +hard_tabs = false +chain_width = 80 +# 以下选项需要 nightly 版本,已注释 +# wrap_comments = true +# format_code_in_doc_comments = true +# format_strings = true +# format_macro_matchers = true +# format_macro_bodies = true +# format_macro_definitions = false + diff --git a/server/src/app.rs b/server/src/app.rs new file mode 100644 index 00000000..e784aa56 --- /dev/null +++ b/server/src/app.rs @@ -0,0 +1,113 @@ +use std::{future::Future, net::SocketAddr}; + +use axum::{Router, middleware}; + +use crate::{ + init_encrypt_secret, middlewares, + routes::route::MetaRoute, + routes::{ + accounts, audit, billing, browser_kernel, environments, extensions, group_permissions, + local_api, messages, preferences, proxies, proxy_visibility, referral, rpa, secret, teams, + templates, time, users, workspace_quotas, workspaces, + }, + svc_ctx::SvcCtx, + utils::IConfig, +}; + +static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!(); + +/// Start the service using its configured port on every network interface. +/// +/// This preserves the original standalone server behavior. Embedded callers +/// should prefer [`serve_on`] or [`serve_on_with_shutdown`] and bind to a +/// loopback address explicitly. +pub async fn serve(config: IConfig) -> anyhow::Result<()> { + let address = SocketAddr::from(([0, 0, 0, 0], config.app.port)); + serve_on(config, address).await +} + +/// Start the service on an explicit address. +pub async fn serve_on(config: IConfig, address: SocketAddr) -> anyhow::Result<()> { + serve_on_with_shutdown(config, address, std::future::pending()).await +} + +/// Start the service on an explicit address and stop it gracefully when the +/// supplied shutdown future completes. +pub async fn serve_on_with_shutdown( + config: IConfig, + address: SocketAddr, + shutdown: F, +) -> anyhow::Result<()> +where + F: Future + Send + 'static, +{ + let svc_ctx = SvcCtx::new(&config).await?; + tracing::info!("Running embedded database migrations"); + MIGRATOR.run(&svc_ctx.db).await?; + tracing::info!("Embedded database migrations completed"); + + init_encrypt_secret(&config).await; + + let listener = tokio::net::TcpListener::bind(address).await?; + let bound_address = listener.local_addr()?; + + let app = register_all_routes(&svc_ctx); + let app = register_middlewares(&svc_ctx, app); + let app = app.with_state(svc_ctx); + + tracing::info!("Starting server on {}", bound_address); + + axum::serve(listener, app).with_graceful_shutdown(shutdown).await?; + Ok(()) +} + +fn register_all_routes(svc_ctx: &SvcCtx) -> Router { + let mut meta_route = MetaRoute::new(svc_ctx.config.app.prefix.clone()); + + secret::register_routes(&mut meta_route); + time::register_routes(&mut meta_route); + users::register_routes(&mut meta_route); + local_api::register_routes(&mut meta_route); + workspaces::register_routes(&mut meta_route); + workspace_quotas::register_routes(&mut meta_route); + teams::register_routes(&mut meta_route); + browser_kernel::register_routes(&mut meta_route); + environments::register_routes(&mut meta_route); + proxies::register_routes(&mut meta_route); + proxy_visibility::register_routes(&mut meta_route); + group_permissions::register_routes(&mut meta_route); + accounts::register_routes(&mut meta_route); + templates::register_routes(&mut meta_route); + billing::register_routes(&mut meta_route); + audit::register_routes(&mut meta_route); + rpa::register_routes(&mut meta_route); + referral::register_routes(&mut meta_route); + extensions::register_routes(&mut meta_route); + preferences::register_routes(&mut meta_route); + messages::register_routes(&mut meta_route); + + tracing::info!("---------- {:?} ----------", meta_route.count()); + meta_route.build() +} + +fn register_middlewares(svc_ctx: &SvcCtx, app: Router) -> Router { + app.route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::encrypt, + )) + .route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::auth, + )) + .route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::local_api_auth, + )) + .route_layer(middleware::from_fn_with_state( + svc_ctx.clone(), + middlewares::decrypt, + )) + .route_layer(middleware::from_fn(middlewares::real_ip)) + .route_layer(middleware::from_fn(middlewares::logger)) + .layer(middlewares::cors()) +} diff --git a/server/src/caches/local_api.rs b/server/src/caches/local_api.rs new file mode 100644 index 00000000..1f71b352 --- /dev/null +++ b/server/src/caches/local_api.rs @@ -0,0 +1,204 @@ +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use uuid::Uuid; + +use crate::dto::LocalApiPermissionDefinitionDto; +use crate::svc_ctx::SvcCtx; + +const LOCAL_API_KEY_CACHE_PREFIX: &str = "local_api:key"; +const LOCAL_API_PERMISSION_CACHE_PREFIX: &str = "local_api:permission"; +const LOCAL_API_PERMISSION_DEFINITION_CACHE_PREFIX: &str = "local_api:permission_definition"; +const LOCAL_API_RATE_CACHE_PREFIX: &str = "local_api:rate"; +const LOCAL_API_KEY_CACHE_TTL: u64 = 60 * 10; +const LOCAL_API_PERMISSION_CACHE_TTL: u64 = 60 * 10; +const MINUTE_WINDOW_SECONDS: i64 = 60; +const HOUR_WINDOW_SECONDS: i64 = 60 * 60; +const DAY_WINDOW_SECONDS: i64 = 60 * 60 * 24; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalApiKeyCache { + pub id: i32, + pub user_uuid: Uuid, + pub is_active: bool, + pub expires_at: Option>, + pub daily_limit: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalApiPermissionCache { + pub is_enabled: bool, + pub rate_limit_per_minute: i32, + pub rate_limit_per_hour: i32, +} + +fn local_api_key_cache_key(key_hash: &str) -> String { + format!("{}:{}", LOCAL_API_KEY_CACHE_PREFIX, key_hash) +} + +fn local_api_permission_cache_key(api_key_id: i32, permission_code: &str) -> String { + format!( + "{}:{}:{}", + LOCAL_API_PERMISSION_CACHE_PREFIX, api_key_id, permission_code + ) +} + +fn local_api_permission_definition_cache_key(permission_code: &str) -> String { + format!( + "{}:{}", + LOCAL_API_PERMISSION_DEFINITION_CACHE_PREFIX, permission_code + ) +} + +fn local_api_rate_cache_key( + api_key_id: i32, + permission_code: Option<&str>, + window_type: &str, + window_key: &str, +) -> String { + if let Some(permission_code) = permission_code { + format!( + "{}:{}:{}:{}:{}", + LOCAL_API_RATE_CACHE_PREFIX, api_key_id, permission_code, window_type, window_key + ) + } else { + format!( + "{}:{}:{}:{}", + LOCAL_API_RATE_CACHE_PREFIX, api_key_id, window_type, window_key + ) + } +} + +pub async fn get_local_api_key_cache( + svc_ctx: &SvcCtx, + key_hash: &str, +) -> Result, anyhow::Error> { + let key = local_api_key_cache_key(key_hash); + svc_ctx.cache.get_json(&key).await +} + +pub async fn set_local_api_key_cache( + svc_ctx: &SvcCtx, + key_hash: &str, + cache: &LocalApiKeyCache, +) -> Result<(), anyhow::Error> { + let key = local_api_key_cache_key(key_hash); + svc_ctx + .cache + .set_json(key, cache, Duration::from_secs(LOCAL_API_KEY_CACHE_TTL)) + .await +} + +pub async fn delete_local_api_key_cache( + svc_ctx: &SvcCtx, + key_hash: &str, +) -> Result<(), anyhow::Error> { + let key = local_api_key_cache_key(key_hash); + svc_ctx.cache.delete(&key).await +} + +pub async fn get_local_api_permission_cache( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: &str, +) -> Result, anyhow::Error> { + let key = local_api_permission_cache_key(api_key_id, permission_code); + svc_ctx.cache.get_json(&key).await +} + +pub async fn set_local_api_permission_cache( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: &str, + cache: &LocalApiPermissionCache, +) -> Result<(), anyhow::Error> { + let key = local_api_permission_cache_key(api_key_id, permission_code); + svc_ctx + .cache + .set_json( + key, + cache, + Duration::from_secs(LOCAL_API_PERMISSION_CACHE_TTL), + ) + .await +} + +pub async fn delete_local_api_permission_cache( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: &str, +) -> Result<(), anyhow::Error> { + let key = local_api_permission_cache_key(api_key_id, permission_code); + svc_ctx.cache.delete(&key).await +} + +pub async fn delete_local_api_permission_caches_for_key( + svc_ctx: &SvcCtx, + api_key_id: i32, +) -> Result<(), anyhow::Error> { + let prefix = format!("{}:{}:", LOCAL_API_PERMISSION_CACHE_PREFIX, api_key_id); + svc_ctx.cache.delete_prefix(&prefix).await +} + +pub async fn get_local_api_permission_definition_cache( + svc_ctx: &SvcCtx, + permission_code: &str, +) -> Result, anyhow::Error> { + let key = local_api_permission_definition_cache_key(permission_code); + svc_ctx.cache.get_json(&key).await +} + +pub async fn set_local_api_permission_definition_cache( + svc_ctx: &SvcCtx, + permission_code: &str, + cache: &LocalApiPermissionDefinitionDto, +) -> Result<(), anyhow::Error> { + let key = local_api_permission_definition_cache_key(permission_code); + svc_ctx + .cache + .set_json( + key, + cache, + Duration::from_secs(LOCAL_API_PERMISSION_CACHE_TTL), + ) + .await +} + +pub async fn delete_local_api_permission_definition_cache( + svc_ctx: &SvcCtx, + permission_code: &str, +) -> Result<(), anyhow::Error> { + let key = local_api_permission_definition_cache_key(permission_code); + svc_ctx.cache.delete(&key).await +} + +pub async fn get_local_api_rate_count( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: Option<&str>, + window_type: &str, + window_key: &str, +) -> Result { + let key = local_api_rate_cache_key(api_key_id, permission_code, window_type, window_key); + Ok(svc_ctx.cache.get_i64(&key).await?.unwrap_or(0)) +} + +pub async fn increment_local_api_rate_count( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: Option<&str>, + window_type: &str, + window_key: &str, +) -> Result { + let key = local_api_rate_cache_key(api_key_id, permission_code, window_type, window_key); + let ttl = match window_type { + "minute" => MINUTE_WINDOW_SECONDS, + "hour" => HOUR_WINDOW_SECONDS, + "day" => DAY_WINDOW_SECONDS, + _ => DAY_WINDOW_SECONDS, + }; + svc_ctx + .cache + .increment(&key, Duration::from_secs(ttl as u64)) + .await +} diff --git a/server/src/caches/mod.rs b/server/src/caches/mod.rs new file mode 100644 index 00000000..f0165b0e --- /dev/null +++ b/server/src/caches/mod.rs @@ -0,0 +1,7 @@ +pub mod local_api; +pub mod store; +pub mod user; + +pub use local_api::*; +pub use store::*; +pub use user::*; diff --git a/server/src/caches/store.rs b/server/src/caches/store.rs new file mode 100644 index 00000000..8bd4b14b --- /dev/null +++ b/server/src/caches/store.rs @@ -0,0 +1,221 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::{Duration, Instant}, +}; + +use dashmap::{DashMap, mapref::entry::Entry}; +use serde::{Serialize, de::DeserializeOwned}; + +#[derive(Clone)] +pub struct CacheStore(Arc); + +#[derive(Default)] +pub struct MemoryCache { + entries: DashMap, + operations: AtomicUsize, +} + +struct CacheEntry { + value: String, + expires_at: Option, +} + +impl CacheEntry { + fn is_expired(&self, now: Instant) -> bool { + self.expires_at.is_some_and(|expires_at| expires_at <= now) + } +} + +impl CacheStore { + pub fn memory() -> Self { + Self(Arc::new(MemoryCache::default())) + } + + pub async fn get_string(&self, key: &str) -> Result, anyhow::Error> { + Ok(self.0.get(key)) + } + + pub async fn set_string( + &self, + key: impl Into, + value: impl Into, + ttl: Duration, + ) -> Result<(), anyhow::Error> { + let key = key.into(); + let value = value.into(); + self.0.set(key, value, ttl); + Ok(()) + } + + pub async fn get_json(&self, key: &str) -> Result, anyhow::Error> + where + T: DeserializeOwned, + { + self.get_string(key) + .await? + .map(|value| serde_json::from_str(&value)) + .transpose() + .map_err(Into::into) + } + + pub async fn set_json( + &self, + key: impl Into, + value: &T, + ttl: Duration, + ) -> Result<(), anyhow::Error> + where + T: Serialize, + { + self.set_string(key, serde_json::to_string(value)?, ttl).await + } + + pub async fn delete(&self, key: &str) -> Result<(), anyhow::Error> { + self.0.entries.remove(key); + Ok(()) + } + + pub async fn delete_prefix(&self, prefix: &str) -> Result<(), anyhow::Error> { + self.0.delete_prefix(prefix); + Ok(()) + } + + pub async fn get_i64(&self, key: &str) -> Result, anyhow::Error> { + self.get_string(key) + .await? + .map(|value| value.parse::()) + .transpose() + .map_err(Into::into) + } + + pub async fn increment(&self, key: &str, ttl: Duration) -> Result { + Ok(self.0.increment(key, ttl)?) + } +} + +impl MemoryCache { + fn get(&self, key: &str) -> Option { + self.maybe_prune(); + let now = Instant::now(); + let value = self.entries.get(key).and_then(|entry| { + if entry.is_expired(now) { + None + } else { + Some(entry.value.clone()) + } + }); + + if value.is_none() { + self.entries.remove_if(key, |_, entry| entry.is_expired(now)); + } + value + } + + fn set(&self, key: String, value: String, ttl: Duration) { + self.maybe_prune(); + self.entries.insert( + key, + CacheEntry { + value, + expires_at: Some(Instant::now() + ttl), + }, + ); + } + + fn delete_prefix(&self, prefix: &str) { + let keys = self + .entries + .iter() + .filter(|entry| entry.key().starts_with(prefix)) + .map(|entry| entry.key().clone()) + .collect::>(); + for key in keys { + self.entries.remove(&key); + } + } + + fn increment(&self, key: &str, ttl: Duration) -> Result { + self.maybe_prune(); + let now = Instant::now(); + match self.entries.entry(key.to_string()) { + Entry::Occupied(mut entry) if !entry.get().is_expired(now) => { + let count = entry.get().value.parse::()? + 1; + entry.get_mut().value = count.to_string(); + Ok(count) + } + Entry::Occupied(mut entry) => { + entry.insert(CacheEntry { + value: "1".to_string(), + expires_at: Some(now + ttl), + }); + Ok(1) + } + Entry::Vacant(entry) => { + entry.insert(CacheEntry { + value: "1".to_string(), + expires_at: Some(now + ttl), + }); + Ok(1) + } + } + } + + fn maybe_prune(&self) { + const PRUNE_INTERVAL: usize = 256; + if self.operations.fetch_add(1, Ordering::Relaxed) % PRUNE_INTERVAL != 0 { + return; + } + + let now = Instant::now(); + self.entries.retain(|_, entry| !entry.is_expired(now)); + } +} + +#[cfg(test)] +mod tests { + use super::CacheStore; + use serde::{Deserialize, Serialize}; + use std::time::Duration; + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Value { + name: String, + } + + #[tokio::test] + async fn memory_cache_supports_values_prefixes_and_counters() { + let cache = CacheStore::memory(); + cache + .set_json( + "group:one", + &Value { name: "one".into() }, + Duration::from_secs(30), + ) + .await + .unwrap(); + cache.set_string("group:two", "two", Duration::from_secs(30)).await.unwrap(); + + assert_eq!( + cache.get_json::("group:one").await.unwrap(), + Some(Value { name: "one".into() }) + ); + assert_eq!( + cache.increment("counter", Duration::from_secs(30)).await.unwrap(), + 1 + ); + assert_eq!( + cache.increment("counter", Duration::from_secs(30)).await.unwrap(), + 2 + ); + + cache.delete_prefix("group:").await.unwrap(); + assert_eq!(cache.get_string("group:one").await.unwrap(), None); + assert_eq!(cache.get_string("group:two").await.unwrap(), None); + + cache.set_string("expired", "value", Duration::ZERO).await.unwrap(); + assert_eq!(cache.get_string("expired").await.unwrap(), None); + } +} diff --git a/server/src/caches/user.rs b/server/src/caches/user.rs new file mode 100644 index 00000000..81ac7084 --- /dev/null +++ b/server/src/caches/user.rs @@ -0,0 +1,75 @@ +use crate::svc_ctx::SvcCtx; +use std::time::Duration; +use uuid::Uuid; + +pub(crate) const REGISTER_CODE_CACHE_KEY: &str = "verification:code:register:send"; +pub(crate) const RESET_PASSWORD_CODE_CACHE_KEY: &str = "verification:code:reset_password:send"; +pub(crate) const CODE_EXPIRATION: u64 = 60 * 5; // 5 分钟过期 +pub(crate) const USER_PUBLIC_KEY_CACHE_KEY: &str = "user:public_key"; +pub(crate) const PUBLIC_KEY_EXPIRATION: u64 = 60 * 60 * 24 * 7; // 7 天过期(与 refresh token 一致) + +/// 设置用户注册的验证码 +pub async fn set_register_code( + svc_ctx: &SvcCtx, + email: &str, + code: &str, +) -> Result<(), anyhow::Error> { + let key = format!("{}:{}", REGISTER_CODE_CACHE_KEY, email); + svc_ctx + .cache + .set_string(key, code, Duration::from_secs(CODE_EXPIRATION)) + .await +} + +/// 获取用户注册的验证码 +pub async fn get_register_code( + svc_ctx: &SvcCtx, + email: &str, +) -> Result, anyhow::Error> { + let key = format!("{}:{}", REGISTER_CODE_CACHE_KEY, email); + svc_ctx.cache.get_string(&key).await +} + +/// 设置重置密码的验证码 +pub async fn set_reset_password_code( + svc_ctx: &SvcCtx, + email: &str, + code: &str, +) -> Result<(), anyhow::Error> { + let key = format!("{}:{}", RESET_PASSWORD_CODE_CACHE_KEY, email); + svc_ctx + .cache + .set_string(key, code, Duration::from_secs(CODE_EXPIRATION)) + .await +} + +/// 获取重置密码的验证码 +pub async fn get_reset_password_code( + svc_ctx: &SvcCtx, + email: &str, +) -> Result, anyhow::Error> { + let key = format!("{}:{}", RESET_PASSWORD_CODE_CACHE_KEY, email); + svc_ctx.cache.get_string(&key).await +} + +/// 设置用户公钥 +pub async fn set_user_public_key( + svc_ctx: &SvcCtx, + user_uuid: &Uuid, + public_key: &str, +) -> Result<(), anyhow::Error> { + let key = format!("{}:{}", USER_PUBLIC_KEY_CACHE_KEY, user_uuid); + svc_ctx + .cache + .set_string(key, public_key, Duration::from_secs(PUBLIC_KEY_EXPIRATION)) + .await +} + +/// 获取用户公钥 +pub async fn get_user_public_key( + svc_ctx: &SvcCtx, + user_uuid: &Uuid, +) -> Result, anyhow::Error> { + let key = format!("{}:{}", USER_PUBLIC_KEY_CACHE_KEY, user_uuid); + svc_ctx.cache.get_string(&key).await +} diff --git a/server/src/cli.rs b/server/src/cli.rs new file mode 100644 index 00000000..3984f6ca --- /dev/null +++ b/server/src/cli.rs @@ -0,0 +1,33 @@ +use clap::{Parser, Subcommand}; + +/// Simprint Server CLI +#[derive(Debug, Parser)] +#[command(name = "simprint-server")] +#[command(author = "Simprint Team")] +#[command(version)] +#[command(about = "Simprint Server - 客户端网关服务")] +pub struct Cli { + /// 配置文件路径 + #[arg(short = 'f', long = "config", help = "配置文件路径 (TOML 格式)")] + pub config: String, + + /// 子命令 + #[command(subcommand)] + pub command: Option, +} + +/// 可用的子命令 +#[derive(Debug, Subcommand)] +pub enum Commands { + /// 启动 HTTP 服务 + Serve, +} + +impl Cli { + /// 获取命令,如果没有指定则默认为 Serve + pub fn command_or_default(self) -> (String, Commands) { + let config = self.config; + let command = self.command.unwrap_or(Commands::Serve); + (config, command) + } +} diff --git a/server/src/database.rs b/server/src/database.rs new file mode 100644 index 00000000..b075596b --- /dev/null +++ b/server/src/database.rs @@ -0,0 +1,25 @@ +use sqlx::{Postgres, postgres::PgPoolOptions}; + +use crate::utils::DatabaseConfig; + +/// Database engine currently used by the imported service models. +/// +/// Keeping this alias in one module gives the SQLite migration a single +/// boundary while the PostgreSQL-specific queries are converted domain by +/// domain. +pub type Db = Postgres; +pub type Pool = sqlx::Pool; +pub type DbPool = Pool; + +pub async fn connect(config: &DatabaseConfig) -> anyhow::Result { + let pool = PgPoolOptions::new() + .max_lifetime(std::time::Duration::from_secs(config.max_lifetime)) + .idle_timeout(std::time::Duration::from_secs(config.idle_timeout)) + .acquire_timeout(std::time::Duration::from_secs(config.acquire_timeout)) + .max_connections(config.max_connections) + .min_connections(config.min_connections) + .connect(&config.url) + .await?; + + Ok(pool) +} diff --git a/server/src/dto/accounts.rs b/server/src/dto/accounts.rs new file mode 100644 index 00000000..4b34f9ee --- /dev/null +++ b/server/src/dto/accounts.rs @@ -0,0 +1,35 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +/// 平台账号 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct PlatformAccountDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub password: Option, + pub status: String, + pub remark: Option, + pub usage_count: Option, + pub environments_count: Option, + pub last_used_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 环境账号关联 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct EnvironmentAccountDto { + pub id: i32, + pub environment_uuid: Uuid, + pub account_uuid: Uuid, + pub sort_order: Option, + pub created_at: DateTime, +} diff --git a/server/src/dto/audit.rs b/server/src/dto/audit.rs new file mode 100644 index 00000000..3ec1b06f --- /dev/null +++ b/server/src/dto/audit.rs @@ -0,0 +1,26 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 审计日志 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AuditLogDto { + pub id: i64, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub action: String, + pub target_type: String, + pub target_uuid: Option, + pub target_name: Option, + pub details: Option, + pub changes: Option, + pub ip_address: Option, + pub user_agent: Option, + pub request_id: Option, + pub created_at: DateTime, + // 用户信息(联表查询) + pub user_name: Option, + pub user_email: Option, +} diff --git a/server/src/dto/billing.rs b/server/src/dto/billing.rs new file mode 100644 index 00000000..9ca1a2bb --- /dev/null +++ b/server/src/dto/billing.rs @@ -0,0 +1,236 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use rust_decimal::Decimal; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 套餐 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct PlanDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub price_per_month: Decimal, + pub price_per_year: Decimal, + pub currency: String, + pub discount_monthly: Option, + pub discount_yearly: Option, + pub max_environments: i32, + pub max_team_members: i32, + pub max_proxies: i32, + pub max_rpa_tasks: i32, + pub is_recommended: Option, + pub sort_order: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 套餐特性 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct PlanFeatureDto { + pub id: i32, + pub plan_uuid: Uuid, + pub feature_key: String, + pub feature_name: String, + pub feature_value: Option, + pub is_included: Option, + pub sort_order: Option, + pub created_at: DateTime, +} + +/// 订阅 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct SubscriptionDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub user_uuid: Uuid, + pub plan_uuid: Uuid, + pub billing_period: String, + pub price: Decimal, + pub currency: String, + pub started_at: DateTime, + pub expires_at: DateTime, + pub next_billing_date: Option, + pub auto_renew: Option, + pub status: String, + pub cancelled_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 用户钱包 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserWalletDto { + pub id: i32, + pub user_uuid: Uuid, + pub balance: Decimal, + pub currency: String, + pub frozen_amount: Decimal, + pub auto_renewal_combined: Decimal, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 钱包交易 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct WalletTransactionDto { + pub id: i64, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub transaction_type: String, + pub amount: Decimal, + pub currency: String, + pub balance_before: Decimal, + pub balance_after: Decimal, + pub description: Option, + pub order_uuid: Option, + pub status: String, + pub created_at: DateTime, +} + +/// 发票 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct InvoiceDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub invoice_number: String, + pub amount: Decimal, + pub currency: String, + pub subscription_uuid: Option, + pub order_uuid: Option, + pub invoice_type: String, + pub status: String, + pub issued_at: Option>, + pub due_at: Option>, + pub paid_at: Option>, + pub invoice_url: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 用户配额 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserQuotaDto { + pub id: i32, + pub user_uuid: Uuid, + pub max_environments: i32, + pub used_environments: i32, + pub max_team_members: i32, + pub max_proxies: i32, + pub used_proxies: i32, + pub max_rpa_tasks: i32, + pub used_rpa_tasks: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 优惠券 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct CouponDto { + pub id: i32, + pub uuid: Uuid, + pub code: String, + pub name: Option, + pub description: Option, + pub discount_type: String, + pub discount_value: Decimal, + pub min_amount: Option, + pub max_discount: Option, + pub max_uses: Option, + pub used_count: i32, + pub max_uses_per_user: Option, + pub valid_from: DateTime, + pub valid_until: Option>, + pub applicable_to: String, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 优惠券使用记录 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct CouponUsageDto { + pub id: i32, + pub coupon_uuid: Uuid, + pub user_uuid: Uuid, + pub order_uuid: Option, + pub discount_amount: Decimal, + pub used_at: DateTime, +} + +/// 用户优惠券 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserCouponDto { + pub id: i32, + pub user_uuid: Uuid, + pub coupon_uuid: Uuid, + pub status: String, + pub issued_at: DateTime, + pub used_at: Option>, + pub expires_at: Option>, +} + +/// 用户优惠券详细信息 DTO(包含优惠券详情) +#[derive(Debug, Clone, Serialize)] +pub struct UserCouponWithDetailsDto { + pub id: i32, + pub user_uuid: Uuid, + pub coupon_uuid: Uuid, + pub status: String, + pub issued_at: DateTime, + pub used_at: Option>, + pub expires_at: Option>, + // 优惠券详细信息 + pub code: String, + pub name: Option, + pub description: Option, + pub discount_type: String, + pub discount_value: Decimal, + pub min_amount: Option, + pub max_discount: Option, +} + +/// 支付订单 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct PaymentOrderDto { + pub id: i64, + pub uuid: Uuid, + pub order_no: String, + pub user_uuid: Uuid, + pub order_type: String, + pub amount: Decimal, + pub currency: String, + pub status: String, + pub payment_channel: Option, + pub external_order_id: Option, + pub description: Option, + pub subscription_uuid: Option, + pub coupon_uuid: Option, + pub original_amount: Option, + pub discount_amount: Option, + pub paid_at: Option>, + pub refunded_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 自动续费服务 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AutoRenewalServiceDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub service_type: String, + pub service_uuid: Option, + pub service_name: String, + pub renewal_price: Decimal, + pub currency: String, + pub next_bill_date: NaiveDate, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/server/src/dto/environments.rs b/server/src/dto/environments.rs new file mode 100644 index 00000000..75529a0b --- /dev/null +++ b/server/src/dto/environments.rs @@ -0,0 +1,307 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +/// 分组 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct GroupDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub team_name: Option, + pub name: String, + pub description: Option, + pub sort_order: Option, + pub created_by: Option, + pub created_by_name: Option, + pub environments_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 标签 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct TagDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub name: String, + pub color: Option, + pub sort_order: Option, + pub environments_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 环境 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct EnvironmentDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Uuid, + pub name: String, + pub description: Option, + pub status: String, + pub group_uuid: Option, + pub proxy_uuid: Option, + pub system_info: Option, + pub kernel_info: Option, + pub fingerprint_summary: Option, + pub last_opened_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 环境列表行(基础查询结果) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct EnvironmentRowDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Uuid, + pub name: String, + pub description: Option, + pub status: String, + pub system_info: Option, + pub kernel_info: Option, + pub fingerprint_summary: Option, + pub group_uuid: Option, + pub proxy_uuid: Option, + pub last_opened_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 环境标签关联行(用于批量查询) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct EnvironmentTagRowDto { + pub environment_uuid: Uuid, + pub tag_id: i32, + pub tag_uuid: Uuid, + pub tag_name: String, + pub tag_color: Option, + pub tag_sort_order: Option, + pub tag_user_uuid: Uuid, + pub tag_team_uuid: Option, + pub tag_environments_count: Option, + pub tag_created_at: chrono::DateTime, + pub tag_updated_at: chrono::DateTime, + pub tag_deleted_at: Option>, +} + +/// 环境账号关联行(用于批量查询) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct EnvironmentAccountRowDto { + pub environment_uuid: Uuid, + pub account_id: i32, + pub account_uuid: Uuid, + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub account_status: String, + pub remark: Option, +} + +/// 分组查询行(用于批量查询) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct GroupRowDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub sort_order: Option, +} + +/// 代理查询行(用于批量查询,排除敏感数据) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct ProxyRowDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub country: Option, + pub city: Option, + pub status: String, + pub latency: Option, + pub last_check_ip: Option, +} + +/// 代理摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxySummaryDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub country: Option, + pub city: Option, + pub status: String, + pub latency: Option, + pub last_check_ip: Option, +} + +/// 分组摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupSummaryDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub sort_order: Option, +} + +/// 标签摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize)] +pub struct TagSummaryDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub color: Option, + pub sort_order: Option, +} + +/// 账号摘要 DTO(用于环境列表,排除敏感数据) +#[derive(Debug, Clone, Serialize)] +pub struct AccountSummaryDto { + pub id: i32, + pub uuid: Uuid, + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub status: String, + pub remark: Option, +} + +/// 环境列表项 DTO(包含完整关联数据) +#[derive(Debug, Clone, Serialize)] +pub struct EnvironmentListItemDto { + // 基础信息 + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub status: String, + pub system_info: Option, + pub kernel_info: Option, + pub fingerprint_summary: Option, + pub last_opened_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + // 分组详情(完整对象) + pub group: Option, + // 代理详情(完整对象) + pub proxy: Option, + // 标签列表(完整对象列表,与环境详情接口保持一致) + pub tags: Vec, + // 账号列表(完整对象列表) + pub accounts: Vec, + // 扩展列表(插件列表) + pub extensions: Vec, +} + +/// 扩展摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtensionSummaryDto { + pub extension_id: String, + pub name: String, + pub version: String, + pub icon_url: Option, + pub download_url: Option, + pub hash: Option, + pub scope: String, // user, team, group-personal, group-team +} + +/// 环境配置 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct EnvironmentConfigDto { + pub id: i32, + pub environment_uuid: Uuid, + pub window_info: serde_json::Value, + pub basic_settings: serde_json::Value, + pub fingerprint_settings: serde_json::Value, + pub device_settings: serde_json::Value, + pub preference_settings: serde_json::Value, + pub project_metadata: serde_json::Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 环境标签关联 DTO +#[derive(Debug, Clone, FromRow)] +pub struct EnvironmentTagDto { + pub id: i32, + pub environment_uuid: Uuid, + pub tag_uuid: Uuid, + pub created_at: DateTime, +} + +/// 环境 URL DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct EnvironmentUrlDto { + pub id: i32, + pub environment_uuid: Uuid, + pub url: String, + pub title: Option, + pub sort_order: Option, + pub created_at: DateTime, +} + +/// 环境 Cookie DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct EnvironmentCookieDto { + pub id: i32, + pub environment_uuid: Uuid, + pub site_input: String, + pub domain: String, + pub name: String, + pub value: String, + pub path: Option, + pub expires_at: Option>, + pub http_only: Option, + pub secure: Option, + pub same_site: Option, + pub created_at: DateTime, +} + +/// 环境 Cookie 分组 DTO +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnvironmentCookieGroupDto { + pub site: String, + pub cookie_text: String, +} + +/// 模板 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TemplateDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub name: String, + pub description: Option, + pub is_public: Option, + pub system_info: Option, + pub kernel_info: Option, + pub config_json: serde_json::Value, + pub usage_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/server/src/dto/extensions.rs b/server/src/dto/extensions.rs new file mode 100644 index 00000000..7cec132e --- /dev/null +++ b/server/src/dto/extensions.rs @@ -0,0 +1,128 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +use crate::utils::storage::get_objects; + +/// 扩展 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ExtensionDto { + pub id: i32, + pub uuid: Uuid, + pub extension_id: String, + pub name: String, + pub description: Option, + pub version: String, + pub category: String, + pub browser: String, + pub developer: Option, + pub homepage: Option, + pub icon_url: Option, + pub download_url: Option, + pub file_size: Option, + pub downloads_count: Option, + pub rating: Option, + pub permissions: Option, + pub status: String, + pub changelog: Option, + pub published_at: Option>, + pub hash: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl ExtensionDto { + /// 将 object path 转换为完整 URL + /// + /// 数据库中存储的是对象存储 object path(如 `ext_id/version/hash.crx`), + /// 返回给客户端时需要组装为完整 URL。 + /// + /// 如果 URL 已经是完整的 http(s) 地址,则不进行转换。 + pub fn transform_urls(&mut self, public_base_url: &str, extension_root: &str) { + // 转换图标 URL + if let Some(path) = &self.icon_url { + if !path.is_empty() && !path.starts_with("http") { + self.icon_url = Some(get_objects::get_extension_icon_url( + public_base_url, + extension_root, + path, + )); + } + } + + // 转换下载 URL + if let Some(path) = &self.download_url { + if !path.is_empty() && !path.starts_with("http") { + self.download_url = Some(get_objects::get_extension_crx_url( + public_base_url, + extension_root, + path, + )); + } + } + } + + /// 批量转换 ExtensionDto 列表中的 object path 为完整 URL + pub fn transform_urls_batch( + extensions: &mut [ExtensionDto], + public_base_url: &str, + extension_root: &str, + ) { + for ext in extensions.iter_mut() { + ext.transform_urls(public_base_url, extension_root); + } + } +} + +/// 用户扩展 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserExtensionDto { + pub id: i32, + pub user_uuid: Uuid, + pub extension_id: String, + pub installed_version: String, + pub status: String, + pub installed_at: DateTime, + pub updated_at: DateTime, +} + +/// 团队扩展 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TeamExtensionDto { + pub id: i32, + pub team_uuid: Uuid, + pub extension_id: String, + pub installed_version: String, + pub installed_by: Uuid, + pub status: String, + pub installed_at: DateTime, + pub updated_at: DateTime, +} + +/// 分组扩展 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct GroupExtensionDto { + pub id: i32, + pub group_uuid: Uuid, + pub extension_id: String, + pub installed_version: String, + pub installed_by: Uuid, + pub status: String, + pub is_team_shared: bool, + pub installed_at: DateTime, + pub updated_at: DateTime, +} + +/// 用户团队插件偏好设置 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserTeamExtensionPreferenceDto { + pub id: i32, + pub user_uuid: Uuid, + pub team_uuid: Uuid, + pub extension_id: String, + pub is_disabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/server/src/dto/group_member_permissions.rs b/server/src/dto/group_member_permissions.rs new file mode 100644 index 00000000..356b82d4 --- /dev/null +++ b/server/src/dto/group_member_permissions.rs @@ -0,0 +1,32 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 分组权限 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct GroupMemberPermissionDto { + pub group_uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub user_uuid: Uuid, + pub permission_type: String, + pub granted_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 分组权限详情 DTO(包含用户信息) +#[derive(Debug, Clone, Serialize)] +pub struct GroupMemberPermissionDetailDto { + pub group_uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub user_uuid: Uuid, + pub permission_type: String, + pub granted_by: Uuid, + pub user_name: Option, + pub user_email: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/server/src/dto/local_api.rs b/server/src/dto/local_api.rs new file mode 100644 index 00000000..256c28b3 --- /dev/null +++ b/server/src/dto/local_api.rs @@ -0,0 +1,97 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, FromRow)] +pub struct LocalApiSettingsDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub enabled: bool, + pub port: i32, + pub remote_access: bool, + pub cors_origins: Value, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct LocalApiKeyDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub key_prefix: String, + pub key_hash: String, + pub api_key: Option, + pub is_active: bool, + pub requests_today: i32, + pub daily_limit: i32, + pub last_reset_date: NaiveDate, + pub last_used_at: Option>, + pub expires_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct LocalApiKeyPermissionDto { + pub id: i32, + pub uuid: Uuid, + pub api_key_id: i32, + pub permission_code: String, + pub is_enabled: bool, + pub rate_limit_per_minute: i32, + pub rate_limit_per_hour: i32, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct LocalApiPermissionDefinitionDto { + pub id: i32, + pub uuid: Uuid, + pub permission_code: String, + pub name: String, + pub description: Option, + pub default_enabled: bool, + pub default_rate_limit_per_minute: i32, + pub default_rate_limit_per_hour: i32, + pub sort_order: i32, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalApiConfigDto { + pub enabled: bool, + pub api_key: String, + pub port: i32, + pub remote_access: bool, + pub cors_origins: Vec, + pub requests_today: i32, + pub daily_limit: i32, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResetLocalApiKeyDto { + pub api_key: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidateLocalApiKeyDto { + pub valid: bool, + pub permission_code: String, + pub requests_today: i32, + pub daily_limit: i32, + pub rate_limit_per_minute: i32, + pub rate_limit_per_hour: i32, +} diff --git a/server/src/dto/maintenance.rs b/server/src/dto/maintenance.rs new file mode 100644 index 00000000..f66e1568 --- /dev/null +++ b/server/src/dto/maintenance.rs @@ -0,0 +1,49 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum MaintenanceType { + #[serde(rename = "scheduled")] + Scheduled, + #[serde(rename = "emergency")] + Emergency, + #[serde(rename = "upgrade")] + Upgrade, +} + +impl FromStr for MaintenanceType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "scheduled" => Ok(MaintenanceType::Scheduled), + "emergency" => Ok(MaintenanceType::Emergency), + "upgrade" => Ok(MaintenanceType::Upgrade), + _ => Err(format!("无效的维护类型: {}", s)), + } + } +} + +impl From for String { + fn from(maintenance_type: MaintenanceType) -> Self { + match maintenance_type { + MaintenanceType::Scheduled => "scheduled".to_string(), + MaintenanceType::Emergency => "emergency".to_string(), + MaintenanceType::Upgrade => "upgrade".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Maintenance { + pub id: i64, + pub name: String, + pub description: Option, + pub status: String, + pub start_time: DateTime, + pub end_time: DateTime, + pub maintenance_type: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/server/src/dto/messages.rs b/server/src/dto/messages.rs new file mode 100644 index 00000000..1227f26c --- /dev/null +++ b/server/src/dto/messages.rs @@ -0,0 +1,60 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 消息 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct MessageDto { + pub id: i32, + pub uuid: Uuid, + pub message_type: String, + pub title: String, + pub content: Option, + pub sender_uuid: Option, + pub recipient_type: String, + pub related_type: Option, + pub related_uuid: Option, + pub metadata: Option, + pub status: String, + pub priority: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 用户消息关联 DTO(包含消息详情和用户状态) +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserMessageDto { + // 消息基本信息 + pub message_uuid: Uuid, + pub message_type: String, + pub title: String, + pub content: Option, + pub sender_uuid: Option, + pub related_type: Option, + pub related_uuid: Option, + pub metadata: Option, + pub priority: String, + pub message_created_at: DateTime, + + // 用户消息状态 + pub is_read: bool, + pub read_at: Option>, + pub action_status: Option, + pub action_at: Option>, + + // 发送者信息(可选,通过 JOIN 获取) + #[sqlx(default)] + pub sender_name: Option, + #[sqlx(default)] + pub sender_email: Option, +} + +/// 消息统计 DTO +#[derive(Debug, Clone, Serialize)] +pub struct MessageStatsDto { + pub total: i64, + pub unread: i64, + pub by_type: std::collections::HashMap, +} diff --git a/server/src/dto/mod.rs b/server/src/dto/mod.rs new file mode 100644 index 00000000..c3e55146 --- /dev/null +++ b/server/src/dto/mod.rs @@ -0,0 +1,47 @@ +pub mod maintenance; +pub mod strategy_types; +pub mod user; +pub mod version_types; +pub mod versions; + +// 新增模块 +pub mod accounts; +pub mod audit; +pub mod billing; +pub mod environments; +pub mod extensions; +pub mod group_member_permissions; +pub mod local_api; +pub mod messages; +pub mod proxies; +pub mod proxy_visible_teams; +pub mod referral; +pub mod rpa; +pub mod system; +pub mod teams; +pub mod workspace_quotas; +pub mod workspaces; + +pub use maintenance::*; +pub use strategy_types::*; +pub use user::*; +pub use version_types::*; +pub use versions::*; + +// 新增导出 +pub use accounts::*; +pub use audit::*; +pub use billing::*; +pub use environments::*; +pub use extensions::*; +pub use group_member_permissions::*; +pub use local_api::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visible_teams::*; +pub use referral::*; +pub use rpa::*; +pub use system::*; +pub use teams::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/server/src/dto/proxies.rs b/server/src/dto/proxies.rs new file mode 100644 index 00000000..6a9da792 --- /dev/null +++ b/server/src/dto/proxies.rs @@ -0,0 +1,43 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 代理 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ProxyDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub owner_uuid: Uuid, + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub ssh_key_encrypted: Option, + pub ssh_passphrase_encrypted: Option, + pub country: Option, + pub city: Option, + pub status: String, + pub latency: Option, + pub last_check_ip: Option, + pub last_checked_at: Option>, + pub environments_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 代理健康检查 DTO +#[derive(Debug, Clone, FromRow)] +pub struct ProxyHealthCheckDto { + pub id: i64, + pub proxy_uuid: Uuid, + pub status: String, + pub latency: Option, + pub ip_address: Option, + pub error_message: Option, + pub checked_at: DateTime, +} diff --git a/server/src/dto/proxy_visible_teams.rs b/server/src/dto/proxy_visible_teams.rs new file mode 100644 index 00000000..92c67309 --- /dev/null +++ b/server/src/dto/proxy_visible_teams.rs @@ -0,0 +1,23 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 代理可见团队 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ProxyVisibleTeamDto { + pub proxy_uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub created_at: DateTime, +} + +/// 代理可见团队详情 DTO(包含团队信息) +#[derive(Debug, Clone, Serialize)] +pub struct ProxyVisibleTeamDetailDto { + pub proxy_uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub team_name: Option, + pub created_at: DateTime, +} diff --git a/server/src/dto/referral.rs b/server/src/dto/referral.rs new file mode 100644 index 00000000..8a31a457 --- /dev/null +++ b/server/src/dto/referral.rs @@ -0,0 +1,153 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 推广链接层级 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ReferralLinkTierDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub unlock_threshold: i32, + pub reward_rate: Decimal, + pub discount_rate: Decimal, + pub description: Option, + pub sort_order: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 推广链接 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ReferralLinkDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub code: String, + pub url: Option, + pub tier_uuid: Option, + pub unlocked: Option, + pub is_current: Option, + pub reward_rate: Decimal, + pub discount_rate: Decimal, + pub registered_users: Option, + pub paid_users: Option, + pub total_consumption: Option, + pub last_30_days_consumption: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 用户邀请关系 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserReferralDto { + pub id: i32, + pub inviter_uuid: Uuid, + pub invitee_uuid: Uuid, + pub link_uuid: Option, + pub status: String, + pub total_consumption: Option, + pub last_30_days_consumption: Option, + pub registered_at: DateTime, + pub activated_at: Option>, + pub first_paid_at: Option>, +} + +/// 被邀请用户列表查询行(用于 `referral/users` 的 JOIN 查询) +/// +/// 注意:这是 SQL 行映射结构,属于 DTO/数据结构层(而不是 models 业务逻辑层)。 +#[derive(Debug, Clone, FromRow)] +pub struct ReferredUserRow { + pub id: i32, + pub email: String, + pub status: String, + pub link_uuid: Option, + pub total_consumption: Option, + pub last_30_days_consumption: Option, + pub registered_at: DateTime, +} + +/// 被邀请用户列表项(对齐前端 `ReferredUser`:camelCase + number) +/// +/// 前端表格字段: +/// - id +/// - email +/// - registeredAt +/// - status +/// - totalConsumption +/// - last30DaysConsumption +/// - linkId +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReferredUserItemDto { + pub id: String, + pub email: String, + pub registered_at: DateTime, + pub status: String, + pub total_consumption: f64, + pub last_30_days_consumption: f64, + pub link_id: String, +} + +/// 推荐奖励 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ReferralRewardDto { + pub id: i64, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub reward_type: String, + pub points: i32, + pub description: Option, + pub referred_user_uuid: Option, + pub link_uuid: Option, + pub status: String, + pub created_at: DateTime, +} + +/// 用户推荐积分 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserReferralPointsDto { + pub id: i32, + pub user_uuid: Uuid, + pub total_points: i32, + pub available_points: i32, + pub used_points: i32, + pub pending_points: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 兑换选项 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RedeemOptionDto { + pub id: i32, + pub uuid: Uuid, + pub redeem_type: String, + pub name: String, + pub description: Option, + pub points_required: i32, + pub value: Decimal, + pub currency: Option, + pub exchange_rate: i32, + pub status: String, + pub sort_order: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 兑换记录 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RedeemRecordDto { + pub id: i64, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub option_uuid: Uuid, + pub points_used: i32, + pub value: Decimal, + pub currency: Option, + pub status: String, + pub created_at: DateTime, + pub completed_at: Option>, +} diff --git a/server/src/dto/rpa.rs b/server/src/dto/rpa.rs new file mode 100644 index 00000000..acc72d93 --- /dev/null +++ b/server/src/dto/rpa.rs @@ -0,0 +1,83 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// RPA 任务 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RpaTaskDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub name: String, + pub description: Option, + pub tags: Option, + pub trigger_type: String, + pub schedule: Option, + pub cron_expression: Option, + pub run_mode: String, + pub retry_count: Option, + pub retry_interval: Option, + pub timeout: Option, + pub concurrency: Option, + pub stop_on_error: Option, + pub notify_on_complete: Option, + pub notify_on_error: Option, + pub status: String, + pub run_count: Option, + pub success_count: Option, + pub environment_count: Option, + pub last_run_at: Option>, + pub next_run_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// RPA 任务步骤 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RpaTaskStepDto { + pub id: i32, + pub uuid: Uuid, + pub task_uuid: Uuid, + pub step_type: String, + pub name: String, + pub config: serde_json::Value, + pub enabled: Option, + pub position_x: Option, + pub position_y: Option, + pub sort_order: Option, + pub next_step_uuid: Option, + pub branch_config: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// RPA 任务环境关联 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RpaTaskEnvironmentDto { + pub id: i32, + pub task_uuid: Uuid, + pub environment_uuid: Uuid, + pub sort_order: Option, + pub created_at: DateTime, +} + +/// RPA 任务执行记录 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RpaTaskRunDto { + pub id: i64, + pub uuid: Uuid, + pub task_uuid: Uuid, + pub status: String, + pub total_steps: i32, + pub completed_steps: i32, + pub failed_steps: i32, + pub started_at: DateTime, + pub finished_at: Option>, + pub duration_ms: Option, + pub result_summary: Option, + pub error_message: Option, + pub logs: Option, +} diff --git a/server/src/dto/strategy_types.rs b/server/src/dto/strategy_types.rs new file mode 100644 index 00000000..04494bbf --- /dev/null +++ b/server/src/dto/strategy_types.rs @@ -0,0 +1,92 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +pub mod processor_config { + use super::*; + + /// 白名单过滤配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct FilterWhitelistConfig { + pub machines: Vec, // 机器码列表 + } + + /// 百分比过滤配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct FilterPercentageConfig { + pub percent: f64, // 百分比 (0-100) + } + + /// 随机过滤配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct FilterRandomConfig { + pub count: usize, // 随机选择的数量 + } + + /// 用户标签过滤配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct FilterUserTagConfig { + pub tags: Vec, // 标签列表 + pub match_all: bool, // true: 必须包含所有标签, false: 包含任意一个标签 + } + + /// 时间段转换配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct TransformTimeRangeConfig { + pub start_hour: u8, // 开始小时 (0-23) + pub end_hour: u8, // 结束小时 (0-23) + pub days: Vec, // 生效星期 (0-6, 0=周日) + } + + /// 延迟分配配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct TransformDelayConfig { + pub delay_seconds: u64, // 延迟秒数 + } + + /// 分步分配配置 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct TransformStepConfig { + pub step_percentage: f64, // 每一步的百分比 + pub step_interval_hours: u32, // 每步间隔(小时) + } +} + +/// 处理器类型枚举 +#[derive(Debug, Clone, PartialEq)] +pub enum ProcessorType { + FilterWhitelist(processor_config::FilterWhitelistConfig), + FilterPercentage(processor_config::FilterPercentageConfig), + FilterRandom(processor_config::FilterRandomConfig), + FilterUserTag(processor_config::FilterUserTagConfig), + TransformTimeRange(processor_config::TransformTimeRangeConfig), + TransformDelay(processor_config::TransformDelayConfig), + TransformStep(processor_config::TransformStepConfig), +} + +impl ProcessorType { + /// 获取处理器类型名称 + pub fn type_name(&self) -> &'static str { + match self { + ProcessorType::FilterWhitelist(_) => "filter_whitelist", + ProcessorType::FilterPercentage(_) => "filter_percentage", + ProcessorType::FilterRandom(_) => "filter_random", + ProcessorType::FilterUserTag(_) => "filter_user_tag", + ProcessorType::TransformTimeRange(_) => "transform_time_range", + ProcessorType::TransformDelay(_) => "transform_delay", + ProcessorType::TransformStep(_) => "transform_step", + } + } +} + +/// 策略类型 +#[derive(Debug, Deserialize, Serialize, FromRow, Clone)] +pub struct StrategyType { + pub id: i32, + pub code: String, + pub name: String, + pub category: Option, + pub description: Option, + pub processor_type: String, + pub config_schema: Option, + pub is_active: bool, +} diff --git a/server/src/dto/system.rs b/server/src/dto/system.rs new file mode 100644 index 00000000..7a8ea75d --- /dev/null +++ b/server/src/dto/system.rs @@ -0,0 +1,27 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 用户偏好设置 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserPreferenceDto { + pub id: i32, + pub user_uuid: Uuid, + pub theme: String, + pub language: String, + pub notifications_enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 系统配置 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct SystemConfigDto { + pub id: i32, + pub config_key: String, + pub config_value: serde_json::Value, + pub description: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/server/src/dto/teams.rs b/server/src/dto/teams.rs new file mode 100644 index 00000000..7e0f27df --- /dev/null +++ b/server/src/dto/teams.rs @@ -0,0 +1,84 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 团队 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TeamDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub name: String, + pub description: Option, + pub owner_uuid: Uuid, + pub avatar_hash: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 团队摘要 DTO(用于登录响应等场景,只包含基本信息) +#[derive(Debug, Clone, Serialize)] +pub struct TeamSummaryDto { + pub uuid: Uuid, + pub name: String, + pub description: Option, +} + +/// 团队成员 DTO(包含用户信息) +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TeamMemberDto { + pub id: i32, + pub team_uuid: Uuid, + pub workspace_uuid: Uuid, + pub user_uuid: Uuid, + pub role: String, + pub joined_at: DateTime, + pub invited_by: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, + // 从 user_infos 表关联的用户信息 + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar: Option, +} + +/// 团队邀请 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TeamInvitationDto { + pub id: i32, + pub uuid: Uuid, + pub team_uuid: Uuid, + pub email: String, + pub role: String, + pub invited_by: Uuid, + pub token: String, + pub expires_at: DateTime, + pub status: String, + pub accepted_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 登录历史 DTO +#[derive(Debug, Clone, FromRow)] +pub struct LoginHistoryDto { + pub id: i64, + pub user_uuid: Uuid, + pub ip_address: String, + pub device_info: Option, + pub user_agent: Option, + pub location: Option, + pub country: Option, + pub city: Option, + pub success: bool, + pub failure_reason: Option, + pub created_at: DateTime, +} diff --git a/server/src/dto/user.rs b/server/src/dto/user.rs new file mode 100644 index 00000000..e49baa0f --- /dev/null +++ b/server/src/dto/user.rs @@ -0,0 +1,31 @@ +use chrono::{DateTime, Utc}; +use sqlx::FromRow; +use uuid::Uuid; + +/// 用户基础信息 DTO +#[derive(Debug, Clone, FromRow)] +pub struct UserDto { + pub uuid: Uuid, + pub id: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 用户详细信息 DTO +#[derive(Debug, Clone, FromRow)] +pub struct UserInfoDto { + pub id: i32, + pub user_uuid: Uuid, + pub nickname: Option, + pub email: String, + pub phone: Option, + pub password: String, + pub avatar_hash: Option, + pub status: String, + pub current_team_uuid: Option, + pub current_workspace_uuid: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/server/src/dto/version_types.rs b/server/src/dto/version_types.rs new file mode 100644 index 00000000..765e086c --- /dev/null +++ b/server/src/dto/version_types.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// 版本类型 +#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] +pub struct VersionType { + pub id: i32, + pub type_code: String, + pub type_name: String, + pub description: Option, + pub sort_order: i32, + pub is_active: bool, + pub is_auto_download: bool, +} diff --git a/server/src/dto/versions.rs b/server/src/dto/versions.rs new file mode 100644 index 00000000..87d35cd0 --- /dev/null +++ b/server/src/dto/versions.rs @@ -0,0 +1,31 @@ +use chrono::DateTime; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// 统一版本表 +#[derive(Debug, Deserialize, Serialize, FromRow, Clone, Default)] +pub struct Version { + pub id: i32, + pub type_id: i32, + pub resource_name: String, + pub version: String, + pub name: Option, + pub notes: Option, + pub platform: Option, + pub url: Option, + pub hash: Option, + pub signature: Option, + pub install_path: Option, + pub file_size: Option, + pub is_latest: bool, + pub status: String, + pub pub_date: Option>, + pub created_at: DateTime, + pub updated_at: Option>, + pub deleted_at: Option>, + pub arch: Option, + pub package_format: Option, + pub requires_extract: bool, + pub entrypoint_template: Option, + pub extract_root: Option, +} diff --git a/server/src/dto/workspace_quotas.rs b/server/src/dto/workspace_quotas.rs new file mode 100644 index 00000000..9aaf60ec --- /dev/null +++ b/server/src/dto/workspace_quotas.rs @@ -0,0 +1,20 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 工作空间配额 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct WorkspaceQuotaDto { + pub workspace_uuid: Uuid, + pub max_environments: i32, + pub used_environments: i32, + pub max_team_members: i32, + pub used_team_members: i32, + pub max_proxies: i32, + pub used_proxies: i32, + pub max_rpa_tasks: i32, + pub used_rpa_tasks: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/server/src/dto/workspaces.rs b/server/src/dto/workspaces.rs new file mode 100644 index 00000000..4058defe --- /dev/null +++ b/server/src/dto/workspaces.rs @@ -0,0 +1,25 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 工作空间 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct WorkspaceDto { + pub uuid: Uuid, + pub name: String, + pub owner_uuid: Uuid, + pub workspace_type: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 工作空间摘要 DTO(用于列表显示等场景) +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceSummaryDto { + pub uuid: Uuid, + pub name: String, + pub workspace_type: String, + pub owner_uuid: Uuid, +} diff --git a/server/src/entitys/accounts.rs b/server/src/entitys/accounts.rs new file mode 100644 index 00000000..700855d5 --- /dev/null +++ b/server/src/entitys/accounts.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询账号列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListAccountsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 账号筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AccountFilters { + pub keyword: Option, + pub platform_name: Option, + pub status: Option, +} + +/// 创建账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateAccountRequest { + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub password: Option, + pub remark: Option, +} + +/// 更新账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateAccountRequest { + pub uuid: Uuid, + pub platform_url: Option, + pub platform_name: Option, + pub account: Option, + pub password: Option, + pub remark: Option, + pub status: Option, +} + +/// 批量导入账号项(客户端已解析好的结构化数据) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchImportAccountItem { + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub password: Option, + pub remark: Option, +} + +/// 批量导入账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchImportAccountsRequest { + pub accounts: Vec, +} + +/// 导出账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExportAccountsRequest { + pub uuids: Option>, + pub format: String, + pub include_password: bool, +} + +// ========== 响应结构体 ========== + +/// 账号列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct AccountListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} diff --git a/server/src/entitys/audit.rs b/server/src/entitys/audit.rs new file mode 100644 index 00000000..403c5f29 --- /dev/null +++ b/server/src/entitys/audit.rs @@ -0,0 +1,83 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; +use crate::dto::AuditLogDto; + +/// 查询审计日志请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListAuditLogsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +// ========== 响应结构体 ========== + +/// 审计日志列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct AuditLogsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 导出响应 +#[derive(Debug, Clone, Serialize)] +pub struct ExportResponse { + pub content: String, + pub filename: String, + pub mime_type: String, +} + +/// 审计统计响应 +#[derive(Debug, Clone, Serialize)] +pub struct AuditStatsResponse { + pub total_logs: i64, + pub logs_today: i64, + pub logs_this_week: i64, + pub logs_this_month: i64, + pub top_actions: Vec, + pub top_target_types: Vec, +} + +/// 操作计数 +#[derive(Debug, Clone, Serialize)] +pub struct ActionCount { + pub action: String, + pub count: i64, +} + +/// 目标类型计数 +#[derive(Debug, Clone, Serialize)] +pub struct TargetTypeCount { + pub target_type: String, + pub count: i64, +} + +/// 审计日志筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuditLogFilters { + pub keyword: Option, + pub action: Option, + pub target_type: Option, + pub user_uuid: Option, + pub date_from: Option, + pub date_to: Option, +} + +/// 导出审计日志请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExportAuditLogsRequest { + pub format: String, + pub filters: Option, + pub max_records: Option, +} + +/// 审计统计请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuditStatsRequest { + pub date_from: Option, + pub date_to: Option, +} diff --git a/server/src/entitys/billing.rs b/server/src/entitys/billing.rs new file mode 100644 index 00000000..ccec45d0 --- /dev/null +++ b/server/src/entitys/billing.rs @@ -0,0 +1,247 @@ +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; +use crate::dto::{ + CouponDto, InvoiceDto, PaymentOrderDto, PlanDto, PlanFeatureDto, + UserCouponWithDetailsDto, WalletTransactionDto, +}; + +/// 订阅套餐请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SubscribePlanRequest { + pub plan_uuid: Uuid, + pub billing_period: String, + pub coupon_code: Option, + /// 支付方式:wallet(钱包)、alipay(支付宝)、wechat(微信) + pub payment_method: Option, +} + +/// 取消订阅请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CancelSubscriptionRequest { + pub subscription_uuid: Uuid, +} + +/// 恢复订阅请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ResumeSubscriptionRequest { + pub subscription_uuid: Uuid, +} + +/// 切换自动续费请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ToggleAutoRenewRequest { + pub subscription_uuid: Uuid, + pub auto_renew: bool, +} + +/// 查询交易记录请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListTransactionsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub transaction_type: Option, +} + +/// 查询发票列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListInvoicesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub status: Option, +} + +/// 验证优惠券请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct VerifyCouponRequest { + pub code: String, + pub amount: Decimal, +} + +/// 创建充值订单请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateRechargeOrderRequest { + pub amount: Decimal, + pub payment_channel: String, +} + +/// 查询支付订单请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListPaymentOrdersRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub order_type: Option, + pub status: Option, +} + +/// 获取用户优惠券列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetUserCouponsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub status: Option, // unused, used, expired +} + +/// 发放优惠券请求(管理员功能) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IssueCouponRequest { + pub coupon_uuid: Uuid, + pub user_uuid: Uuid, + pub expires_at: Option>, // 可选的自定义过期时间 +} + +/// 批量发放优惠券请求(管理员功能) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchIssueCouponRequest { + pub coupon_uuid: Uuid, + pub user_uuids: Vec, + pub expires_at: Option>, // 可选的自定义过期时间 +} + +/// 获取套餐价格请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetPlanPriceRequest { + pub plan_uuid: Uuid, + pub billing_period: String, // monthly, yearly + pub coupon_code: Option, +} + +/// 获取套餐列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetPlansRequest { + /// 优惠券代码(可选) + pub coupon_code: Option, + /// 计费周期(用于计算价格,默认为 monthly) + #[serde(default = "default_billing_period")] + pub billing_period: String, +} + +fn default_billing_period() -> String { + "monthly".to_string() +} + +// ========== 响应结构体 ========== + +/// 带特性的套餐结构 +#[derive(Debug, Clone, Serialize)] +pub struct PlanWithFeatures { + pub plan: PlanDto, + pub features: Vec, + /// 计算后的价格信息(如果提供了优惠券代码) + #[serde(skip_serializing_if = "Option::is_none")] + pub calculated_price: Option, +} + +/// 套餐价格信息(用于套餐列表响应) +#[derive(Debug, Clone, Serialize)] +pub struct PlanPriceInfo { + pub original_price: Decimal, + pub plan_discount: Decimal, + pub coupon_discount: Decimal, + pub final_price: Decimal, + pub total_saved: Decimal, + pub billing_period: String, // monthly, yearly +} + +/// 套餐列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct PlansResponse { + pub plans: Vec, +} + +/// 套餐详情响应 +#[derive(Debug, Clone, Serialize)] +pub struct PlanDetailResponse { + pub plan: PlanDto, + pub features: Vec, +} + +/// 订阅响应 +#[derive(Debug, Clone, Serialize)] +pub struct SubscribeResponse { + pub subscription_uuid: Uuid, +} + +/// 交易记录列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct TransactionsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 发票列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct InvoicesListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 优惠券验证响应 +#[derive(Debug, Clone, Serialize)] +pub struct VerifyCouponResponse { + pub coupon_uuid: Uuid, + pub discount_type: String, + pub discount_value: rust_decimal::Decimal, + pub discount_amount: rust_decimal::Decimal, +} + +/// 创建订单响应 +#[derive(Debug, Clone, Serialize)] +pub struct CreateOrderResponse { + pub order_uuid: Uuid, + pub order_no: String, +} + +/// 支付订单列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct PaymentOrdersListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 优惠券验证结果 +#[derive(Debug, Clone, Serialize)] +pub struct CouponValidationResult { + pub coupon: CouponDto, + pub discount_amount: rust_decimal::Decimal, +} + +/// 账户信息响应 +#[derive(Debug, Clone, Serialize)] +pub struct AccountInfoResponse { + pub email: String, + pub wallet_balance: rust_decimal::Decimal, + pub gift_balance: rust_decimal::Decimal, + pub currency: String, + pub subscription: Option, + pub quota: crate::dto::WorkspaceQuotaDto, + pub monthly_billing: rust_decimal::Decimal, +} + +/// 用户优惠券列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct UserCouponsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 套餐价格响应 +#[derive(Debug, Clone, Serialize)] +pub struct PlanPriceResponse { + pub original_price: Decimal, + pub plan_discount: Decimal, // 套餐级折扣金额 + pub coupon_discount: Decimal, // 优惠券折扣金额 + pub final_price: Decimal, // 最终价格 + pub total_saved: Decimal, // 总节省金额 + pub coupon_info: Option, // 优惠券信息(如果使用了优惠券) +} diff --git a/server/src/entitys/browser_kernel.rs b/server/src/entitys/browser_kernel.rs new file mode 100644 index 00000000..edc14954 --- /dev/null +++ b/server/src/entitys/browser_kernel.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +/// 查询浏览器内核列表请求 +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct ListBrowserKernelsRequest { + /// 平台过滤,如 windows/darwin/linux,为空表示不过滤 + pub platform: Option, + /// 版本类型过滤,如 SIMPRINT_KERNEL_CHROMIUM,为空表示查询所有 SIMPRINT_KERNEL_* 类型 + pub type_code: Option, +} diff --git a/server/src/entitys/common.rs b/server/src/entitys/common.rs new file mode 100644 index 00000000..b5259dc0 --- /dev/null +++ b/server/src/entitys/common.rs @@ -0,0 +1,65 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ========== 请求参数 ========== + +/// 分页请求参数 +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct Pagination { + #[serde(default = "default_page")] + pub page: i64, + #[serde(default = "default_page_size")] + pub page_size: i64, + #[serde(default)] + pub sort_by: Option, + #[serde(default)] + pub sort_order: Option, +} + +fn default_page() -> i64 { + 1 +} + +fn default_page_size() -> i64 { + 20 +} + +/// UUID 请求参数 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UuidRequest { + pub uuid: Uuid, +} + +/// 批量 UUID 请求参数 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchUuidRequest { + pub uuids: Vec, +} + +// ========== 响应结构体 ========== + +/// 创建资源响应(返回新创建资源的 UUID) +#[derive(Debug, Clone, Serialize)] +pub struct CreateResponse { + pub uuid: Uuid, +} + +/// 创建资源响应(返回新创建资源的数字 ID) +#[derive(Debug, Clone, Serialize)] +pub struct IdResponse { + pub id: i32, +} + +/// 邀请响应 +#[derive(Debug, Clone, Serialize)] +pub struct InviteResponse { + pub invitation_uuid: Uuid, +} + +/// 批量导入响应 +#[derive(Debug, Clone, Serialize)] +pub struct BatchImportResponse { + pub success_count: i32, + pub failed_count: i32, + pub errors: Vec, +} diff --git a/server/src/entitys/environments.rs b/server/src/entitys/environments.rs new file mode 100644 index 00000000..7e2bb520 --- /dev/null +++ b/server/src/entitys/environments.rs @@ -0,0 +1,235 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询环境列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListEnvironmentsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 环境筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct EnvironmentFilters { + pub keyword: Option, + pub status: Option, + pub group_uuid: Option, + pub tag_uuids: Option>, + pub created_from: Option, + pub created_to: Option, +} + +/// 创建环境请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateEnvironmentRequest { + pub name: String, + pub description: Option, + pub group_uuid: Option, + pub tag_uuids: Option>, + pub account_uuids: Option>, + pub proxy_uuid: Option, // 代理 UUID(单个,可选) + pub cookies: Option>, + pub urls: Option>, + pub config: EnvironmentConfigRequest, +} + +/// 环境配置请求(对应 WindowConfig) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct EnvironmentConfigRequest { + pub window_info: serde_json::Value, + pub basic_settings: serde_json::Value, + pub fingerprint_settings: serde_json::Value, + pub device_settings: serde_json::Value, + pub preference_settings: serde_json::Value, + #[serde(default)] + pub project_metadata: serde_json::Value, +} + +/// 批量创建环境请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchCreateEnvironmentRequest { + pub environments: Vec, // 环境创建请求数组 +} + +/// 更新环境请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateEnvironmentRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub group_uuid: Option, + pub cookies: Option>, + pub urls: Option>, + pub config: Option, +} + +/// 设置环境代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SetEnvironmentProxyRequest { + pub uuid: Uuid, + pub proxy_uuid: Option, +} + +/// 设置环境账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SetEnvironmentAccountsRequest { + pub uuid: Uuid, + pub account_uuids: Vec, +} + +/// 分配标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AssignTagsRequest { + pub uuid: Uuid, + pub tag_uuids: Vec, +} + +/// 批量分配标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchAssignTagRequest { + pub env_uuids: Vec, + pub tag_uuid: Uuid, +} + +/// 批量移除标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchRemoveTagsRequest { + pub env_uuids: Vec, + pub tag_uuid: Option, +} + +/// 移除标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RemoveTagRequest { + pub uuid: Uuid, + pub tag_uuid: Uuid, +} + +/// 移动到分组请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MoveToGroupRequest { + pub uuid: Uuid, + pub group_uuid: Option, +} + +/// 批量移动到分组请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchMoveToGroupRequest { + pub env_uuids: Vec, + pub group_uuid: Uuid, +} + +// ============ Environment URLs ============ + +/// 添加环境 URL 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AddEnvironmentUrlRequest { + pub environment_uuid: Uuid, + pub url: String, + pub title: Option, + pub sort_order: Option, +} + +/// 批量添加环境 URL 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchAddEnvironmentUrlsRequest { + pub environment_uuid: Uuid, + pub urls: Vec, +} + +/// URL 输入 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UrlInput { + pub url: String, + pub title: Option, + pub sort_order: Option, +} + +/// 删除环境 URL 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DeleteEnvironmentUrlRequest { + pub id: i32, +} + +/// 清空环境 URL 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ClearEnvironmentUrlsRequest { + pub environment_uuid: Uuid, +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AddEnvironmentCookieRequest { + pub environment_uuid: Uuid, + pub site: String, + pub cookie_text: String, +} + +/// 批量添加环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchAddEnvironmentCookiesRequest { + pub environment_uuid: Uuid, + pub cookies: Vec, +} + +/// Cookie 分组输入 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CookieGroupInput { + pub site: String, + pub cookie_text: String, +} + +/// 删除环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DeleteEnvironmentCookieRequest { + pub id: i32, +} + +/// 清空环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ClearEnvironmentCookiesRequest { + pub environment_uuid: Uuid, +} + +/// Cookie 输入结构(用于批量添加) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CookieInput { + pub site_input: String, + pub domain: String, + pub name: String, + pub value: String, + pub path: Option, + pub http_only: Option, + pub secure: Option, + pub same_site: Option, +} + +// ========== 响应结构体 ========== + +/// 环境列表响应(使用与环境详情一致的数据结构) +#[derive(Debug, Clone, Serialize)] +pub struct EnvironmentListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 环境详情响应(包含完整配置) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnvironmentDetailResponse { + pub environment: crate::dto::EnvironmentDto, + pub config: Option, + pub cookies: Vec, + pub urls: Vec, + pub tags: Vec, + pub accounts: Vec, + pub group: Option, // 分组完整信息 + pub proxy: Option, // 代理完整信息 + pub extensions: Vec, // 扩展列表 +} diff --git a/server/src/entitys/extensions.rs b/server/src/entitys/extensions.rs new file mode 100644 index 00000000..86e6ff13 --- /dev/null +++ b/server/src/entitys/extensions.rs @@ -0,0 +1,228 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 创建扩展参数(模型层入参) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateExtensionParams { + pub extension_id: String, + pub name: String, + pub description: Option, + pub version: String, + pub category: String, + pub browser: String, + pub developer: Option, + pub homepage: Option, + pub icon_url: Option, + pub download_url: Option, + pub file_size: Option, + pub downloads_count: Option, + pub permissions: Option, + pub rating: Option, + pub changelog: Option, + pub published_at: Option>, + pub hash: Option, +} + +/// 更新扩展参数(模型层入参) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateExtensionParams { + pub name: Option, + pub description: Option, + pub version: Option, + pub category: Option, + pub developer: Option, + pub homepage: Option, + pub icon_url: Option, + pub download_url: Option, + pub file_size: Option, + pub downloads_count: Option, + pub permissions: Option, + pub rating: Option, + pub changelog: Option, + pub published_at: Option>, + pub hash: Option, +} + +/// 查询扩展列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListExtensionsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 同步扩展响应 +#[derive(Debug, Clone, Serialize)] +pub struct SyncExtensionResponse { + /// 扩展 UUID + pub uuid: Uuid, + /// 扩展 ID + pub extension_id: String, + /// 是否为新创建 + pub created: bool, +} + +/// 扩展筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExtensionFilters { + pub keyword: Option, + pub category: Option, + pub installed_only: Option, + pub sort_by: Option, + pub sort_order: Option, +} + +/// 安装扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct InstallExtensionRequest { + pub extension_id: String, + /// 安装目标: user, team, group + pub target_type: Option, + /// 分组 UUID 数组(用于安装到分组,即使只有一个分组也需要传入数组) + #[serde(skip_serializing_if = "Option::is_none")] + pub group_ids: Option>, + /// 是否为团队共享(仅 target_type=group 时有效) + /// true: 团队所有成员可用,false: 仅创建者可用 + #[serde(skip_serializing_if = "Option::is_none")] + pub is_team_shared: Option, +} + +/// 卸载扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UninstallExtensionRequest { + pub extension_id: String, + /// 卸载类型:user、team、group + /// 如果不指定,默认为 user + pub target_type: Option, + /// 目标 UUID(用于 team 和 group 类型) + /// - target_type=team: 表示 team_uuid + /// - target_type=group: 表示 group_uuid + #[serde(skip_serializing_if = "Option::is_none")] + pub target_uuid: Option, +} + +/// 更新扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateExtensionRequest { + pub extension_id: String, +} + +/// 批量更新扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchUpdateExtensionsRequest { + pub extension_ids: Vec, +} + +/// 扩展 ID 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExtensionIdRequest { + pub extension_id: String, +} + +/// 禁用/启用扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ToggleExtensionRequest { + pub extension_id: String, +} + +// ========== 响应结构体 ========== + +use crate::dto::ExtensionDto; + +/// 扩展列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ExtensionsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 获取已安装扩展请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetInstalledExtensionsRequest { + /// 范围过滤:all, user, team + #[serde(default = "default_scope")] + pub scope: String, +} + +fn default_scope() -> String { + "all".to_string() +} + +/// 已安装扩展响应 +#[derive(Debug, Clone, Serialize)] +pub struct InstalledExtensionsResponse { + pub user_extensions: Vec, + pub team_extensions: Vec, +} + +/// 已安装扩展项(包含完整扩展详情) +#[derive(Debug, Clone, Serialize)] +pub struct InstalledExtensionItem { + pub extension_id: String, + pub name: String, + pub version: String, + pub installed_version: String, + pub has_update: bool, + pub status: String, + pub installed_at: chrono::DateTime, + /// 扩展主页 URL + #[serde(skip_serializing_if = "Option::is_none")] + pub homepage: Option, + /// 扩展图标 URL + #[serde(skip_serializing_if = "Option::is_none")] + pub icon_url: Option, + /// 团队 UUID(如果是团队安装的) + #[serde(skip_serializing_if = "Option::is_none")] + pub team_uuid: Option, + /// 安装范围:user 或 team + pub scope: String, + /// 扩展描述 + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// 扩展分类 + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + /// 浏览器类型 + #[serde(skip_serializing_if = "Option::is_none")] + pub browser: Option, + /// 开发者/作者 + #[serde(skip_serializing_if = "Option::is_none")] + pub developer: Option, + /// 下载量 + #[serde(skip_serializing_if = "Option::is_none")] + pub downloads_count: Option, + /// 评分 + #[serde(skip_serializing_if = "Option::is_none")] + pub rating: Option, + /// 权限列表 + #[serde(skip_serializing_if = "Option::is_none")] + pub permissions: Option, + /// 文件大小 + #[serde(skip_serializing_if = "Option::is_none")] + pub file_size: Option, + /// 更新时间 + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option>, + /// 关联的分组列表 + #[serde(skip_serializing_if = "Option::is_none")] + pub groups: Option>, +} + +/// 扩展关联的分组信息 +#[derive(Debug, Clone, Serialize)] +pub struct ExtensionGroup { + pub uuid: Uuid, + pub name: String, +} + +/// 批量更新响应 +#[derive(Debug, Clone, Serialize)] +pub struct BatchUpdateResponse { + pub updated: u64, +} diff --git a/server/src/entitys/group_member_permissions.rs b/server/src/entitys/group_member_permissions.rs new file mode 100644 index 00000000..2954e66d --- /dev/null +++ b/server/src/entitys/group_member_permissions.rs @@ -0,0 +1,54 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 授予分组权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GrantGroupPermissionRequest { + pub group_uuid: Uuid, + pub user_uuid: Uuid, + pub permission_type: String, // 'read', 'write', 'manage' +} + +/// 撤销分组权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RevokeGroupPermissionRequest { + pub group_uuid: Uuid, + pub user_uuid: Uuid, +} + +/// 查询用户分组权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListUserGroupPermissionsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub user_uuid: Uuid, + pub group_uuid: Option, +} + +/// 检查分组权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CheckGroupPermissionRequest { + pub group_uuid: Uuid, + pub user_uuid: Uuid, + pub permission_type: String, // 'read', 'write', 'manage' +} + +// ========== 响应结构体 ========== + +/// 分组权限列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct GroupPermissionListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 检查权限响应 +#[derive(Debug, Clone, Serialize)] +pub struct CheckPermissionResponse { + pub has_permission: bool, + pub permission_type: Option, +} diff --git a/server/src/entitys/groups.rs b/server/src/entitys/groups.rs new file mode 100644 index 00000000..5856f3f2 --- /dev/null +++ b/server/src/entitys/groups.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询分组列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListGroupsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub keyword: Option, +} + +/// 创建分组请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateGroupRequest { + pub name: String, + pub description: Option, +} + +/// 更新分组请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateGroupRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub sort_order: Option, +} + +/// 分配分组到团队请求(已废弃,分组创建时即指定团队) +#[derive(Debug, Clone, Deserialize, Serialize)] +#[deprecated(note = "分组创建时即指定团队,不再需要分配")] +pub struct AssignGroupToTeamRequest { + pub uuid: Uuid, + pub team_uuid: Uuid, +} diff --git a/server/src/entitys/local_api.rs b/server/src/entitys/local_api.rs new file mode 100644 index 00000000..a190a53c --- /dev/null +++ b/server/src/entitys/local_api.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct GetLocalApiConfigRequest {} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ResetLocalApiKeyRequest {} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateLocalApiConfigRequest { + pub enabled: Option, + pub port: Option, + pub remote_access: Option, + pub cors_origins: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidateLocalApiKeyRequest { + pub api_key: String, + pub permission_code: String, +} diff --git a/server/src/entitys/maintenance.rs b/server/src/entitys/maintenance.rs new file mode 100644 index 00000000..39106d06 --- /dev/null +++ b/server/src/entitys/maintenance.rs @@ -0,0 +1,42 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::dto::maintenance::MaintenanceType; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateMaintenanceRequest { + pub name: String, + pub description: Option, + pub status: String, + pub start_time: DateTime, + pub end_time: DateTime, + pub maintenance_type: MaintenanceType, +} + +/// 获取维护详情请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetMaintenanceRequest { + pub id: i64, +} + +/// 查询维护列表请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct ListMaintenancesRequest { + pub limit: Option, + pub offset: Option, +} + +/// 更新维护状态请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateMaintenanceStatusRequest { + pub id: i64, + pub status: String, +} + +/// 结束维护请求(可为空 body) +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct EndMaintenanceRequest {} + +/// 获取当前活跃维护请求(可为空 body) +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct GetActiveMaintenanceRequest {} diff --git a/server/src/entitys/messages.rs b/server/src/entitys/messages.rs new file mode 100644 index 00000000..6d9832a6 --- /dev/null +++ b/server/src/entitys/messages.rs @@ -0,0 +1,71 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 创建消息请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateMessageRequest { + pub message_type: String, // team_invitation, system_notification, etc. + pub title: String, + pub content: Option, // JSON 格式的扩展数据 + pub recipient_uuids: Vec, // 接收者列表(recipient_type='single' 或 'multiple' 时使用) + pub recipient_type: String, // single, multiple, team, all + pub related_type: Option, + pub related_uuid: Option, + pub priority: Option, // low, normal, high, urgent + pub metadata: Option, // JSON 格式的扩展信息 +} + +/// 查询消息列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListMessagesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 消息筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MessageFilters { + pub message_type: Option, + pub is_read: Option, + pub action_status: Option, + pub priority: Option, +} + +/// 标记消息为已读请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MarkMessageReadRequest { + pub message_uuid: Uuid, +} + +/// 批量标记已读请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchMarkReadRequest { + pub message_uuids: Vec, +} + +/// 处理消息请求(用于邀请类消息) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct HandleMessageRequest { + pub message_uuid: Uuid, + pub action: String, // accept, reject +} + +/// 消息列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct MessageListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 消息统计响应 +#[derive(Debug, Clone, Serialize)] +pub struct MessageStatsResponse { + pub total: i64, + pub unread: i64, + pub by_type: std::collections::HashMap, +} diff --git a/server/src/entitys/mod.rs b/server/src/entitys/mod.rs new file mode 100644 index 00000000..9d711089 --- /dev/null +++ b/server/src/entitys/mod.rs @@ -0,0 +1,57 @@ +pub mod maintenance; +pub mod strategy_types; +pub mod user; +pub mod version_types; +pub mod versions; + +// 新增模块 +pub mod accounts; +pub mod audit; +pub mod billing; +pub mod browser_kernel; +pub mod common; +pub mod environments; +pub mod extensions; +pub mod group_member_permissions; +pub mod groups; +pub mod local_api; +pub mod messages; +pub mod proxies; +pub mod proxy_visible_teams; +pub mod referral; +pub mod rpa; +pub mod settings; +pub mod tags; +pub mod teams; +pub mod templates; +pub mod workspace_quotas; +pub mod workspaces; + +pub use maintenance::*; +pub use strategy_types::*; +pub use user::*; +pub use version_types::*; +pub use versions::*; + +// 新增导出 +pub use accounts::*; +pub use audit::*; +pub use billing::*; +pub use browser_kernel::*; +pub use common::*; +pub use environments::*; +pub use extensions::*; +pub use group_member_permissions::*; +pub use groups::*; +pub use local_api::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visible_teams::*; +pub use referral::*; +pub use rpa::*; +pub use settings::*; +pub use tags::*; +pub use teams::*; +pub use templates::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/server/src/entitys/proxies.rs b/server/src/entitys/proxies.rs new file mode 100644 index 00000000..40bafed8 --- /dev/null +++ b/server/src/entitys/proxies.rs @@ -0,0 +1,80 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询代理列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListProxiesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 代理筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ProxyFilters { + pub keyword: Option, + pub proxy_type: Option, + pub status: Option, + pub country: Option, +} + +/// 创建代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateProxyRequest { + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub country: Option, + pub city: Option, + pub ssh_key: Option, + pub ssh_passphrase: Option, +} + +/// 更新代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateProxyRequest { + pub uuid: Uuid, + pub name: Option, + pub host: Option, + pub port: Option, + pub proxy_type: Option, + pub username: Option, + pub password: Option, + pub country: Option, + pub city: Option, +} + +/// 批量导入代理项(客户端已解析好的结构化数据) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchImportProxyItem { + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub country: Option, + pub city: Option, +} + +/// 批量导入代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchImportProxiesRequest { + pub proxies: Vec, +} + +// ========== 响应结构体 ========== + +/// 代理列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ProxyListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} diff --git a/server/src/entitys/proxy_visible_teams.rs b/server/src/entitys/proxy_visible_teams.rs new file mode 100644 index 00000000..687e4b53 --- /dev/null +++ b/server/src/entitys/proxy_visible_teams.rs @@ -0,0 +1,50 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 设置代理可见性请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SetProxyVisibleRequest { + pub proxy_uuid: Uuid, + pub team_uuid: Uuid, +} + +/// 移除代理可见性请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RemoveProxyVisibleRequest { + pub proxy_uuid: Uuid, + pub team_uuid: Uuid, +} + +/// 批量设置代理可见性请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchSetProxyVisibleRequest { + pub proxy_uuid: Uuid, + pub team_uuids: Vec, +} + +/// 查询代理可见团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListProxyVisibleTeamsRequest { + pub proxy_uuid: Uuid, +} + +/// 查询可见代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListVisibleProxiesRequest { + pub workspace_uuid: Uuid, + pub team_uuid: Option, +} + +// ========== 响应结构体 ========== + +/// 代理可见团队列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ProxyVisibleTeamListResponse { + pub items: Vec, +} + +/// 可见代理列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct VisibleProxyListResponse { + pub items: Vec, +} diff --git a/server/src/entitys/referral.rs b/server/src/entitys/referral.rs new file mode 100644 index 00000000..20d2dba9 --- /dev/null +++ b/server/src/entitys/referral.rs @@ -0,0 +1,141 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 切换推广链接请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SwitchReferralLinkRequest { + pub link_uuid: Uuid, +} + +/// 查询奖励记录请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListReferralRewardsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub keyword: Option, + pub reward_type: Option, + pub status: Option, +} + +/// 查询被邀请用户请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListReferredUsersRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub keyword: Option, + pub status: Option, +} + +/// 兑换积分请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RedeemPointsRequest { + pub option_uuid: Uuid, + pub points: i32, +} + +// ========== 响应结构体 ========== + +use crate::dto::{ + RedeemOptionDto, RedeemRecordDto, ReferralLinkDto, ReferralLinkTierDto, ReferralRewardDto, + ReferredUserItemDto, UserReferralPointsDto, +}; +use rust_decimal::Decimal; + +/// 推广统计响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferralStatsResponse { + pub total_referrals: i32, + pub paid_referrals: i32, + pub total_consumption: Decimal, + pub last_30_days_consumption: Decimal, + pub total_rewards: i32, + pub available_points: i32, + pub current_tier: Option, + pub next_tier: Option, + pub upgrade_progress: i32, +} + +/// 推广积分摘要(用于看板聚合) +#[derive(Debug, Clone, Serialize)] +pub struct ReferralPointsSummary { + pub available_points: i32, + pub pending_points: i32, + pub total_rewards: i32, +} + +/// 推广看板聚合响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferralDashboardResponse { + pub stats: ReferralStatsResponse, + pub links: Vec, + pub current_link: Option, + pub tiers: Vec, + pub points: ReferralPointsSummary, +} + +/// 套餐页推广摘要响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferralPlanSummaryResponse { + /// 最近 30 天推广产生的消费金额对应的预估收益(按照当前层级 reward_rate 估算) + pub referral_value_last_30_days: Decimal, + /// 当前订阅套餐的月度价格(如果有订阅) + pub current_plan_monthly_price: Option, + /// 推广收益覆盖当前套餐费用的比例(0-1) + pub coverage_ratio: Option, +} + +/// 推广链接列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferralLinksListResponse { + pub items: Vec, + pub current_link: Option, +} + +/// 奖励记录列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferralRewardsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 被邀请用户列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ReferredUsersListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 兑换选项列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct RedeemOptionsListResponse { + pub items: Vec, +} + +/// 兑换响应 +#[derive(Debug, Clone, Serialize)] +pub struct RedeemResponse { + pub record_uuid: Uuid, + pub points_used: i32, + pub value: Decimal, +} + +/// 用户积分响应 +#[derive(Debug, Clone, Serialize)] +pub struct UserPointsResponse { + pub points: UserReferralPointsDto, +} + +/// 兑换记录列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct RedeemRecordsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} diff --git a/server/src/entitys/rpa.rs b/server/src/entitys/rpa.rs new file mode 100644 index 00000000..ad830b47 --- /dev/null +++ b/server/src/entitys/rpa.rs @@ -0,0 +1,135 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListRpaTasksRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RpaTaskFilters { + pub keyword: Option, + pub status: Option, + pub trigger_type: Option, + pub tags: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateRpaTaskRequest { + pub name: String, + pub description: Option, + pub tags: Option>, + pub trigger_type: String, + pub schedule: Option, + pub cron_expression: Option, + pub run_mode: String, + pub retry_count: Option, + pub retry_interval: Option, + pub timeout: Option, + pub concurrency: Option, + pub stop_on_error: Option, + pub notify_on_complete: Option, + pub notify_on_error: Option, + pub environment_uuids: Option>, + pub steps: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RpaTaskStepRequest { + pub step_type: String, + pub name: String, + pub config: serde_json::Value, + pub enabled: Option, + pub position_x: Option, + pub position_y: Option, + pub sort_order: Option, + pub next_step_uuid: Option, + pub branch_config: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateRpaTaskRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub tags: Option>, + pub trigger_type: Option, + pub schedule: Option, + pub cron_expression: Option, + pub run_mode: Option, + pub retry_count: Option, + pub retry_interval: Option, + pub timeout: Option, + pub concurrency: Option, + pub stop_on_error: Option, + pub notify_on_complete: Option, + pub notify_on_error: Option, + pub environment_uuids: Option>, + pub steps: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RunRpaTaskRequest { + pub uuid: Uuid, + pub environment_uuids: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DuplicateRpaTaskRequest { + pub uuid: Uuid, + pub new_name: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExportRpaTaskRequest { + pub uuid: Uuid, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ImportRpaTaskRequest { + pub import_data: String, + pub name: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListRpaRunsRequest { + pub task_uuid: Uuid, + #[serde(flatten)] + pub pagination: Pagination, + pub status: Option, +} + +use crate::dto::{RpaTaskDto, RpaTaskRunDto, RpaTaskStepDto}; + +#[derive(Debug, Clone, Serialize)] +pub struct RpaTaskListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RpaTaskDetailResponse { + pub task: RpaTaskDto, + pub steps: Vec, + pub environment_uuids: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RpaRunsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ExportRpaTaskResponse { + pub content: String, + pub filename: String, +} diff --git a/server/src/entitys/settings.rs b/server/src/entitys/settings.rs new file mode 100644 index 00000000..7786cc19 --- /dev/null +++ b/server/src/entitys/settings.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +/// 更新用户偏好请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdatePreferencesRequest { + pub theme: Option, + pub language: Option, + pub notifications_enabled: Option, +} + +/// 更新用户信息请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateProfileRequest { + pub nickname: Option, + pub avatar_hash: Option, +} + +/// 修改密码请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ChangePasswordRequest { + pub old_password: String, + pub new_password: String, +} + +/// 查询登录历史请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListLoginHistoryRequest { + pub page: i32, + pub page_size: i32, +} diff --git a/server/src/entitys/strategy_types.rs b/server/src/entitys/strategy_types.rs new file mode 100644 index 00000000..2ce5716b --- /dev/null +++ b/server/src/entitys/strategy_types.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; + +/// 根据ID获取策略类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetStrategyTypeByIdRequest { + pub id: i32, +} + +/// 根据代码获取策略类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetStrategyTypeByCodeRequest { + pub code: String, +} + +/// 根据分类查询策略类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct ListStrategyTypesByCategoryRequest { + pub category: String, +} + +/// 策略类型列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct StrategyTypeListResponse { + pub list: Vec, +} + +/// 策略类型详细信息响应(包含配置示例) +#[derive(Debug, Deserialize, Serialize)] +pub struct StrategyTypeDetailResponse { + pub strategy_type: crate::dto::strategy_types::StrategyType, + pub config_example: String, // JSON 配置示例 +} diff --git a/server/src/entitys/tags.rs b/server/src/entitys/tags.rs new file mode 100644 index 00000000..49e6d29f --- /dev/null +++ b/server/src/entitys/tags.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询标签列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListTagsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub keyword: Option, +} + +/// 创建标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateTagRequest { + pub name: String, + pub color: Option, +} + +/// 更新标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateTagRequest { + pub uuid: Uuid, + pub name: Option, + pub color: Option, + pub sort_order: Option, +} diff --git a/server/src/entitys/teams.rs b/server/src/entitys/teams.rs new file mode 100644 index 00000000..d28ced2b --- /dev/null +++ b/server/src/entitys/teams.rs @@ -0,0 +1,130 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 创建团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateTeamRequest { + pub workspace_uuid: Uuid, + pub name: String, + pub description: Option, +} + +/// 更新团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateTeamRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub avatar_hash: Option, +} + +/// 切换团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SwitchTeamRequest { + pub team_uuid: Uuid, +} + +/// 查询团队成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListTeamMembersRequest { + pub workspace_uuid: Uuid, + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 团队成员筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TeamMemberFilters { + pub keyword: Option, + pub role: Option, + pub status: Option, +} + +/// 邀请成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct InviteMemberRequest { + pub email: String, + pub role: String, +} + +/// 取消邀请请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CancelInviteRequest { + pub invitation_uuid: Uuid, +} + +/// 更新成员角色请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateMemberRoleRequest { + pub member_uuid: Uuid, + pub role: String, +} + +/// 更新成员状态请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateMemberStatusRequest { + pub member_uuid: Uuid, + pub status: String, +} + +/// 移除成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RemoveMemberRequest { + pub member_uuid: Uuid, +} + +/// 批量移除成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchRemoveMembersRequest { + pub member_uuids: Vec, +} + +/// 接受邀请请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AcceptInvitationRequest { + pub token: String, +} + +/// 拒绝邀请请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RejectInvitationRequest { + pub token: String, +} + +// ========== 响应结构体 ========== + +/// 团队列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct TeamListResponse { + pub current_team_uuid: Option, + pub teams: Vec, +} + +/// 团队列表项 +#[derive(Debug, Clone, Serialize)] +pub struct TeamItem { + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub role: String, + pub members_count: i64, + pub is_current: bool, +} + +/// 团队成员列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct MemberListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 接受邀请响应 +#[derive(Debug, Clone, Serialize)] +pub struct AcceptInvitationResponse { + pub team_uuid: Uuid, +} diff --git a/server/src/entitys/templates.rs b/server/src/entitys/templates.rs new file mode 100644 index 00000000..66cdf325 --- /dev/null +++ b/server/src/entitys/templates.rs @@ -0,0 +1,94 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询模板列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListTemplatesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub keyword: Option, + pub is_public: Option, +} + +/// 创建模板请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateTemplateRequest { + pub name: String, + pub description: Option, + pub is_public: Option, + /// 完整的环境详情数据(EnvironmentDetailResponse 的 JSON 格式) + /// 如果提供了 environment_uuid,则此字段会被忽略,后端会自动获取环境详情 + pub environment_data: Option, + /// 环境 UUID(如果提供,后端会自动获取该环境的完整详情数据) + pub environment_uuid: Option, +} + +/// 更新模板请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateTemplateRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub is_public: Option, + pub config_json: Option, +} + +/// 应用模板请求(更新现有环境) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ApplyTemplateRequest { + pub template_uuid: Uuid, + pub environment_uuid: Uuid, +} + +/// 从模板创建环境请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateFromTemplateRequest { + pub template_uuid: Uuid, + pub name: Option, // 如果提供,将覆盖模板中的名称 + pub description: Option, // 如果提供,将覆盖模板中的描述 + pub group_uuid: Option, // 如果提供,将覆盖模板中的分组 +} + +/// 获取模板详情请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetTemplateRequest { + pub uuid: Uuid, + /// 是否用于创建环境(如果为 true,将检查关联数据是否存在) + pub for_create: Option, +} + +// ========== 响应结构体 ========== + +/// 模板列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct TemplateListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 关联数据状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssociationsStatus { + /// 分组是否存在 + pub group_exists: bool, + /// 标签是否存在(按 UUID 映射) + pub tags_exist: std::collections::HashMap, + /// 账号是否存在(按 UUID 映射) + pub accounts_exist: std::collections::HashMap, + /// 代理是否存在 + pub proxy_exists: bool, +} + +/// 模板详情响应(包含关联数据状态) +#[derive(Debug, Clone, Serialize)] +pub struct TemplateDetailResponse { + /// 模板数据 + #[serde(flatten)] + pub template: crate::dto::TemplateDto, + /// 关联数据状态(仅在 for_create=true 时返回) + pub associations_status: Option, +} diff --git a/server/src/entitys/user.rs b/server/src/entitys/user.rs new file mode 100644 index 00000000..8df1108c --- /dev/null +++ b/server/src/entitys/user.rs @@ -0,0 +1,132 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 注册请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct RegisterRequest { + pub email: String, + pub password: String, + pub code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub nickname: Option, // 昵称(可选) + #[serde(skip_serializing_if = "Option::is_none")] + pub public_secret_key: Option, // 用户公钥(可选) + #[serde(skip_serializing_if = "Option::is_none")] + pub referral_code: Option, // 推荐码(可选) +} + +/// 基本登录请求(邮箱 + 密码) +#[derive(Debug, Deserialize, Serialize)] +pub struct BasicLoginData { + pub email: String, + pub password: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_secret_key: Option, +} + +/// 记住密码登录请求(邮箱 + refresh_token) +#[derive(Debug, Deserialize, Serialize)] +pub struct RememberLoginData { + pub email: String, + pub refresh_token: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_secret_key: Option, +} + +/// 登录请求(统一结构,通过枚举区分两种登录方式) +/// +/// 使用 serde 的 tag 特性,根据 "login_type" 字段自动反序列化为对应的变体 +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "login_type", rename_all = "snake_case")] +pub enum LoginRequest { + /// 基本登录(邮箱 + 密码) + Basic(BasicLoginData), + /// 记住密码登录(邮箱 + refresh_token) + Remember(RememberLoginData), +} + +/// 刷新 Token 请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct RefreshTokenRequest { + pub refresh_token: String, +} + +/// 更新用户信息请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct UpdateUserRequest { + pub nickname: Option, + pub phone: Option, + pub email: Option, +} + +/// 修改密码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct UpdatePasswordRequest { + pub old_password: String, + pub new_password: String, +} + +/// 校验当前用户密码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct VerifyPasswordRequest { + pub password: String, +} + +/// 重置密码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct ResetPasswordRequest { + pub email: String, + pub code: String, + pub new_password: String, +} + +/// 发送验证码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct SendCodeRequest { + pub email: String, + pub r#type: String, // register 或 reset_password +} + +/// 用户信息响应 +#[derive(Debug, Serialize)] +pub struct UserResponse { + pub uuid: Uuid, + pub id: String, + pub nickname: Option, + pub email: String, + pub phone: Option, + pub avatar_hash: Option, + /// 头像完整 URL(当 avatar_hash 存在时由服务端拼接) + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + /// 当前团队详细信息(如果存在) + pub current_team: Option, + /// 当前工作空间详细信息(如果存在) + pub current_workspace: Option, +} + +/// 登录响应 +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub access_token: String, + pub refresh_token: String, + pub user_info: Option, +} + +/// 注册响应 +#[derive(Debug, Serialize)] +pub struct RegisterResponse { + pub access_token: String, + pub refresh_token: String, + pub user_info: Option, +} + +/// 密码校验响应 +#[derive(Debug, Serialize)] +pub struct VerifyPasswordResponse { + pub valid: bool, +} diff --git a/server/src/entitys/version_types.rs b/server/src/entitys/version_types.rs new file mode 100644 index 00000000..3f0c5019 --- /dev/null +++ b/server/src/entitys/version_types.rs @@ -0,0 +1,69 @@ +use serde::{Deserialize, Serialize}; + +/// 创建版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateVersionTypeRequest { + pub type_code: String, + pub type_name: String, + pub description: Option, + pub sort_order: Option, +} + +/// 更新版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateVersionTypeRequest { + pub type_name: Option, + pub description: Option, + pub sort_order: Option, + pub is_active: Option, + pub is_auto_download: Option, +} + +/// 根据ID获取版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetVersionTypeByIdRequest { + pub id: i32, +} + +/// 根据代码获取版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetVersionTypeByCodeRequest { + pub type_code: String, +} + +/// 查询所有版本类型请求(可为空 body) +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct ListAllVersionTypesRequest {} + +/// 查询激活的版本类型请求(可为空 body) +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct ListActiveVersionTypesRequest {} + +/// 更新版本类型请求(含 id) +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateVersionTypeHandleRequest { + pub id: i32, + pub type_name: Option, + pub description: Option, + pub sort_order: Option, + pub is_active: Option, +} + +/// 删除版本类型请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct DeleteVersionTypeRequest { + pub id: i32, +} + +/// 切换版本类型状态请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct ToggleVersionTypeStatusRequest { + pub id: i32, + pub is_active: bool, +} + +/// 版本类型列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct VersionTypeListResponse { + pub list: Vec, +} diff --git a/server/src/entitys/versions.rs b/server/src/entitys/versions.rs new file mode 100644 index 00000000..434ffb88 --- /dev/null +++ b/server/src/entitys/versions.rs @@ -0,0 +1,132 @@ +use chrono::DateTime; +use serde::{Deserialize, Serialize}; + +/// 创建版本请求 +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct CreateVersionRequest { + pub type_id: i32, + pub resource_name: String, + pub version: String, + pub name: Option, + pub notes: Option, + pub platform: Option, + pub url: Option, + pub hash: Option, + pub signature: Option, + pub install_path: Option, + pub file_size: Option, + pub pub_date: Option>, + pub arch: Option, + pub package_format: Option, + pub requires_extract: Option, + pub entrypoint_template: Option, + pub extract_root: Option, +} + +/// 更新版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateVersionRequest { + pub name: Option, + pub notes: Option, + pub platform: Option, + pub url: Option, + pub hash: Option, + pub signature: Option, + pub install_path: Option, + pub file_size: Option, + pub status: Option, + pub is_latest: Option, + pub arch: Option, + pub package_format: Option, + pub requires_extract: Option, + pub entrypoint_template: Option, + pub extract_root: Option, +} + +/// 版本列表响应 +#[derive(Debug, Deserialize, Serialize)] +pub struct VersionListResponse { + pub total: i64, + pub list: Vec, +} + +/// 查询版本参数 +#[derive(Debug, Deserialize, Serialize)] +pub struct QueryVersionParams { + pub resource_name: Option, + pub platform: Option, + pub status: Option, +} + +/// 根据ID获取版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetVersionByIdRequest { + pub id: i32, +} + +/// 根据资源名称和版本号获取版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetVersionByNameAndVersionRequest { + pub resource_name: String, + pub version: String, +} + +/// 获取最新版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct GetLatestVersionRequest { + pub resource_name: String, + pub platform: String, +} + +/// 查询版本列表请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct ListVersionsRequest { + pub params: QueryVersionParams, + pub page_num: Option, + pub page_size: Option, +} + +/// 更新版本请求(含 id) +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateVersionHandleRequest { + pub id: i32, + pub name: Option, + pub notes: Option, + pub platform: Option, + pub url: Option, + pub hash: Option, + pub signature: Option, + pub install_path: Option, + pub file_size: Option, + pub status: Option, + pub is_latest: Option, +} + +/// 删除版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct DeleteVersionRequest { + pub id: i32, +} + +/// 设置最新版本请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct SetLatestVersionRequest { + pub type_id: i32, + pub resource_name: String, + pub version_id: i32, +} + +/// 版本回退请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct RollbackVersionRequest { + pub type_id: i32, + pub resource_name: String, + pub target_version_id: i32, +} + +/// 版本对比请求 +#[derive(Debug, Deserialize, Serialize)] +pub struct CompareVersionsRequest { + pub version_id_1: i32, + pub version_id_2: i32, +} diff --git a/server/src/entitys/workspace_quotas.rs b/server/src/entitys/workspace_quotas.rs new file mode 100644 index 00000000..2346bf8f --- /dev/null +++ b/server/src/entitys/workspace_quotas.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 获取工作空间配额请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetWorkspaceQuotaRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_uuid: Option, +} + +/// 更新配额使用情况请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateQuotaUsageRequest { + pub workspace_uuid: Uuid, + pub quota_type: String, // 'environments', 'proxies', 'team_members', 'rpa_tasks' + pub increment: bool, // true 为增加,false 为减少 + pub amount: i32, // 增加或减少的数量 +} + +// ========== 响应结构体 ========== + +/// 工作空间配额响应 +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceQuotaResponse { + pub workspace_uuid: Uuid, + pub max_environments: i32, + pub used_environments: i32, + pub max_team_members: i32, + pub used_team_members: i32, + pub max_proxies: i32, + pub used_proxies: i32, + pub max_rpa_tasks: i32, + pub used_rpa_tasks: i32, +} diff --git a/server/src/entitys/workspaces.rs b/server/src/entitys/workspaces.rs new file mode 100644 index 00000000..72f43674 --- /dev/null +++ b/server/src/entitys/workspaces.rs @@ -0,0 +1,57 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 创建工作空间请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateWorkspaceRequest { + pub name: String, + pub workspace_type: Option, +} + +/// 更新工作空间请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateWorkspaceRequest { + pub uuid: Uuid, + pub name: Option, +} + +/// 切换工作空间请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SwitchWorkspaceRequest { + pub workspace_uuid: Uuid, +} + +/// 查询工作空间列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListWorkspacesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 工作空间筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WorkspaceFilters { + pub workspace_type: Option, + pub keyword: Option, +} + +// ========== 响应结构体 ========== + +/// 工作空间列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceListResponse { + pub current_workspace_uuid: Option, + pub workspaces: Vec, +} + +/// 工作空间列表项 +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceItem { + pub uuid: Uuid, + pub name: String, + pub workspace_type: String, + pub is_current: bool, +} diff --git a/server/src/errors.rs b/server/src/errors.rs new file mode 100644 index 00000000..6d22fa1c --- /dev/null +++ b/server/src/errors.rs @@ -0,0 +1,105 @@ +use thiserror::Error; + +/// Simprint Server 错误类型 +#[derive(Error, Debug)] +pub enum SimprintError { + /// 用户不存在 + #[error("用户不存在")] + UserNotFound, + + /// 邮箱已被注册 + #[error("邮箱已被注册")] + EmailAlreadyExists, + + /// 邮箱或密码错误 + #[error("邮箱或密码错误")] + InvalidCredentials, + + /// 用户已被禁用 + #[error("用户已被禁用")] + UserDisabled, + + /// 验证码错误或已过期 + #[error("验证码错误或已过期")] + VerificationCodeExpired, + + /// 机器不存在 + #[error("机器不存在")] + MachineNotFound, + + /// 机器已被绑定 + #[error("机器已被绑定")] + MachineAlreadyBound, + + /// 用户未绑定到机器 + #[error("用户未绑定到机器")] + MachineNotBound, + + /// 版本不存在 + #[error("版本不存在")] + VersionNotFound, + + /// 版本已存在 + #[error("版本已存在")] + VersionAlreadyExists, + + /// 版本类型不存在 + #[error("版本类型不存在")] + VersionTypeNotFound, + + /// 版本号为空 + #[error("版本号不能为空")] + VersionEmpty, + + /// 资源名称为空 + #[error("资源名称不能为空")] + ResourceNameEmpty, + + /// 灰度发布不存在 + #[error("灰度发布不存在")] + GrayReleaseNotFound, + + /// 灰度分配失败 + #[error("灰度分配失败")] + GrayAllocationFailed, + + /// 策略类型不存在 + #[error("策略类型不存在")] + StrategyTypeNotFound, + + /// 策略配置无效 + #[error("策略配置无效")] + InvalidStrategyConfig, + + /// 维护不存在 + #[error("维护不存在")] + MaintenanceNotFound, + + /// 数据库操作失败 + #[error("数据库操作失败: {0}")] + DatabaseError(#[from] sqlx::Error), + + /// Anyhow错误 + #[error("操作失败: {0}")] + AnyhowError(#[from] anyhow::Error), + + /// JSON序列化错误 + #[error("JSON序列化错误: {0}")] + JsonError(#[from] serde_json::Error), + + /// 错误的请求 + #[error("错误的请求: {0}")] + InvalidRequest(String), + + /// 其他错误 + #[error("{0}")] + Other(String), +} + +impl From<&str> for SimprintError { + fn from(err: &str) -> Self { + SimprintError::Other(err.to_string()) + } +} + +// Note: Error conversion to Response is handled in handlers layer via map_err diff --git a/server/src/handlers.rs b/server/src/handlers.rs new file mode 100644 index 00000000..b341009f --- /dev/null +++ b/server/src/handlers.rs @@ -0,0 +1,49 @@ +mod health; +mod secret; +mod time; +mod users; + +// 新增模块 +pub mod accounts; +pub mod local_api; +pub mod audit; +pub mod billing; +pub mod browser_kernel; +pub mod environments; +pub mod extensions; +pub mod group_permissions; +pub mod messages; +pub mod preferences; +pub mod proxies; +pub mod proxy_visibility; +pub mod referral; +pub mod rpa; +pub mod teams; +pub mod templates; +pub mod workspace_quotas; +pub mod workspaces; + +pub use health::*; +pub use secret::*; +pub use time::*; +pub use users::*; + +// 新增导出 +pub use accounts::*; +pub use local_api::*; +pub use audit::*; +pub use billing::*; +pub use browser_kernel::*; +pub use environments::*; +pub use extensions::*; +pub use group_permissions::*; +pub use messages::*; +pub use preferences::*; +pub use proxies::*; +pub use proxy_visibility::*; +pub use referral::*; +pub use rpa::*; +pub use teams::*; +pub use templates::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/server/src/handlers/accounts.rs b/server/src/handlers/accounts.rs new file mode 100644 index 00000000..47f1a9eb --- /dev/null +++ b/server/src/handlers/accounts.rs @@ -0,0 +1,145 @@ +use axum::{extract::Extension, extract::State}; + +use crate::entitys::{ + AccountListResponse, BatchImportAccountsRequest, BatchImportResponse, BatchUuidRequest, + CreateAccountRequest, CreateResponse, ListAccountsRequest, UpdateAccountRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取账号列表 +pub async fn get_accounts_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (accounts, total) = services::accounts::get_accounts_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(AccountListResponse { + items: accounts, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取账号详情 +pub async fn get_account_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let account = services::accounts::get_account_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(account))) +} + +/// 创建账号 +pub async fn create_account_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let account_uuid = services::accounts::create_account_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: account_uuid }), + )) +} + +/// 更新账号 +pub async fn update_account_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::accounts::update_account_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除账号 +pub async fn delete_account_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::accounts::delete_account_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 批量删除账号 +pub async fn batch_delete_accounts_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let count = services::accounts::batch_delete_accounts_service(&svc_ctx, &payload.uuids) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), Some(count))) +} + +/// 批量导入账号 +pub async fn batch_import_accounts_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let result = services::accounts::batch_import_accounts_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("导入完成"), + Some(BatchImportResponse { + success_count: result.success_count, + failed_count: result.failed_count, + errors: result.errors, + }), + )) +} diff --git a/server/src/handlers/audit.rs b/server/src/handlers/audit.rs new file mode 100644 index 00000000..244cce25 --- /dev/null +++ b/server/src/handlers/audit.rs @@ -0,0 +1,98 @@ +use axum::extract::{Extension, State}; + +use crate::dto::AuditLogDto; +use crate::entitys::{ + AuditLogsListResponse, AuditStatsRequest, AuditStatsResponse, ExportAuditLogsRequest, + ExportResponse, ListAuditLogsRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取审计日志列表 +pub async fn get_audit_logs_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let current_user_uuid = ctx.user_uuid_unwrap(); + let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (items, total) = + services::audit::get_audit_logs_service(&svc_ctx, current_user_uuid, team_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(AuditLogsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取审计日志详情 +pub async fn get_audit_log_detail_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let log = services::audit::get_audit_log_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(log))) +} + +/// 导出审计日志 +pub async fn export_audit_logs_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let current_user_uuid = ctx.user_uuid_unwrap(); + let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (content, filename, mime_type) = services::audit::export_audit_logs_service( + &svc_ctx, + current_user_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("导出成功"), + Some(ExportResponse { + content, + filename, + mime_type, + }), + )) +} + +/// 获取审计统计 +pub async fn get_audit_stats_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(_payload): Json, +) -> Result { + let current_user_uuid = ctx.user_uuid_unwrap(); + let team_uuid = services::teams::get_current_team_service(&svc_ctx, current_user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let stats = services::audit::get_audit_stats_service(&svc_ctx, current_user_uuid, team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(stats))) +} diff --git a/server/src/handlers/billing.rs b/server/src/handlers/billing.rs new file mode 100644 index 00000000..4e75f169 --- /dev/null +++ b/server/src/handlers/billing.rs @@ -0,0 +1,409 @@ +use axum::extract::{Extension, State}; + +use crate::dto::{ + AutoRenewalServiceDto, PaymentOrderDto, SubscriptionDto, UserWalletDto, +}; +use crate::entitys::{ + AccountInfoResponse, CancelSubscriptionRequest, CreateOrderResponse, CreateRechargeOrderRequest, + GetPlanPriceRequest, GetPlansRequest, GetUserCouponsRequest, InvoicesListResponse, ListInvoicesRequest, + ListPaymentOrdersRequest, ListTransactionsRequest, PaymentOrdersListResponse, + PlanDetailResponse, PlanPriceResponse, PlansResponse, ResumeSubscriptionRequest, + SubscribePlanRequest, SubscribeResponse, ToggleAutoRenewRequest, TransactionsListResponse, + UserCouponsListResponse, UuidRequest, VerifyCouponRequest, VerifyCouponResponse, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +// ============ Plans ============ + +/// 获取套餐列表(包含特性) +pub async fn get_plans_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let user_uuid = ctx.user_uuid_unwrap(); + let plans_with_features = services::plans::get_plans_service( + &svc_ctx, + user_uuid, + payload.coupon_code.as_deref(), + &payload.billing_period, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let plans = plans_with_features + .into_iter() + .map(|pwf| crate::entitys::PlanWithFeatures { + plan: pwf.plan, + features: pwf.features, + calculated_price: pwf.calculated_price, + }) + .collect(); + + Ok(Response::success( + Some("获取成功"), + Some(PlansResponse { plans }), + )) +} + +/// 获取套餐详情 +pub async fn get_plan_detail_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let (plan, features) = services::plans::get_plan_detail_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(PlanDetailResponse { plan, features }), + )) +} + +// ============ Subscriptions ============ + +/// 获取当前订阅 +pub async fn get_current_subscription_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let subscription = + services::subscriptions::get_workspace_subscription_service(&svc_ctx, workspace_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(subscription))) +} + +/// 订阅套餐 +pub async fn subscribe_plan_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let subscription_uuid = services::subscriptions::subscribe_plan_service( + &svc_ctx, + workspace_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("订阅成功"), + Some(SubscribeResponse { subscription_uuid }), + )) +} + +/// 取消订阅 +pub async fn cancel_subscription_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::subscriptions::cancel_subscription_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + payload.subscription_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("取消成功"), None)) +} + +/// 恢复订阅 +pub async fn resume_subscription_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::subscriptions::resume_subscription_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + payload.subscription_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("恢复成功"), None)) +} + +/// 切换自动续费 +pub async fn toggle_auto_renew_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::subscriptions::toggle_auto_renew_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + payload.subscription_uuid, + payload.auto_renew, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("设置成功"), None)) +} + +// ============ Wallet ============ + +/// 获取钱包信息 +pub async fn get_wallet_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let wallet = services::wallet::get_wallet_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(wallet))) +} + +/// 获取交易记录 +pub async fn get_transactions_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = + services::wallet::get_transactions_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(TransactionsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +// ============ Invoices ============ + +/// 获取发票列表 +pub async fn get_invoices_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = + services::billing::get_invoices_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(InvoicesListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +// ============ Quotas ============ + +/// 获取工作空间配额 +pub async fn get_quota_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let quota = services::workspace_quotas::get_workspace_quota_service( + &svc_ctx, + &crate::entitys::GetWorkspaceQuotaRequest { + workspace_uuid: Some(workspace_uuid), + }, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(quota))) +} + +// ============ Coupons ============ + +/// 验证优惠券 +pub async fn verify_coupon_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = + services::coupons::validate_coupon_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("优惠券有效"), + Some(VerifyCouponResponse { + coupon_uuid: result.coupon.uuid, + discount_type: result.coupon.discount_type, + discount_value: result.coupon.discount_value, + discount_amount: result.discount_amount, + }), + )) +} + +/// 获取用户优惠券列表 +pub async fn get_user_coupons_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = services::coupons::get_user_coupons_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(result))) +} + +/// 获取用户可用优惠券 +pub async fn get_available_coupons_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + let result = services::coupons::get_available_coupons_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(result))) +} + +/// 获取套餐价格(包含折扣计算) +pub async fn get_plan_price_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = services::plans::calculate_plan_price_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("计算成功"), Some(result))) +} + +// ============ Payment Orders ============ + +/// 创建充值订单 +pub async fn create_recharge_order_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (order_uuid, order_no) = + services::orders::create_recharge_order_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("订单创建成功"), + Some(CreateOrderResponse { + order_uuid, + order_no, + }), + )) +} + +/// 获取支付订单列表 +pub async fn get_payment_orders_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = + services::orders::get_payment_orders_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(PaymentOrdersListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 查询订单状态 +pub async fn get_order_status_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let order = + services::orders::get_order_status_service(&svc_ctx, ctx.user_uuid_unwrap(), payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(order))) +} + +// ============ Auto Renewal Services ============ + +/// 获取自动续费服务列表 +pub async fn get_auto_renewal_services_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + let services = + services::billing::get_auto_renewal_services_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(services))) +} + +// ============ Account Info ============ + +/// 获取账户信息 +pub async fn get_account_info_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let account_info = services::billing::get_account_info_service( + &svc_ctx, + workspace_uuid, + ctx.user_uuid_unwrap(), + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(account_info))) +} diff --git a/server/src/handlers/browser_kernel.rs b/server/src/handlers/browser_kernel.rs new file mode 100644 index 00000000..cd671211 --- /dev/null +++ b/server/src/handlers/browser_kernel.rs @@ -0,0 +1,23 @@ +use axum::extract::State; +use std::collections::HashMap; + +use crate::entitys::browser_kernel::ListBrowserKernelsRequest; +use crate::services::browser_kernel::get_browser_kernel_list_service; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 查询浏览器内核最新版本列表 +pub async fn list_browser_kernels_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result>> { + let kernels = get_browser_kernel_list_service( + &svc_ctx, + payload.platform, + payload.type_code, + ) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("获取浏览器内核列表成功"), Some(kernels))) +} diff --git a/server/src/handlers/environments.rs b/server/src/handlers/environments.rs new file mode 100644 index 00000000..270ed50b --- /dev/null +++ b/server/src/handlers/environments.rs @@ -0,0 +1,815 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + AddEnvironmentCookieRequest, AddEnvironmentUrlRequest, AssignTagsRequest, + BatchAssignTagRequest, BatchCreateEnvironmentRequest, BatchMoveToGroupRequest, + BatchRemoveTagsRequest, BatchUuidRequest, ClearEnvironmentCookiesRequest, + ClearEnvironmentUrlsRequest, CreateEnvironmentRequest, CreateGroupRequest, CreateResponse, + DeleteEnvironmentCookieRequest, DeleteEnvironmentUrlRequest, EnvironmentDetailResponse, + EnvironmentListResponse, IdResponse, ListEnvironmentsRequest, MoveToGroupRequest, + RemoveTagRequest, SetEnvironmentAccountsRequest, SetEnvironmentProxyRequest, + UpdateEnvironmentRequest, UpdateGroupRequest, UpdateTagRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +// ============ Groups ============ + +/// 创建分组 +pub async fn create_group_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let group_uuid = services::groups::create_group_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "group", + group_uuid, + &payload.name, + "创建分组" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: group_uuid }), + )) +} + +/// 获取分组列表 +pub async fn get_groups_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + // 从当前团队获取工作空间和团队 UUID + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let team = services::teams::get_team_service(&svc_ctx, team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let groups = + services::groups::get_groups_service(&svc_ctx, team.workspace_uuid, team_uuid, 1, 100) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(groups))) +} + +/// 更新分组 +pub async fn update_group_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + services::groups::update_group_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除分组 +pub async fn delete_group_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + services::groups::delete_group_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + payload.uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!(&svc_ctx, &ctx, "delete", "group", payload.uuid, "删除分组").await; + + Ok(Response::success(Some("删除成功"), None)) +} + +// ============ Tags ============ + +/// 创建标签 +pub async fn create_tag_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let tag_uuid = + services::tags::create_tag_service(&svc_ctx, ctx.user_uuid_unwrap(), team_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "tag", + tag_uuid, + &payload.name, + "创建标签" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: tag_uuid }), + )) +} + +/// 获取标签列表 +pub async fn get_tags_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let tags = services::tags::get_tags_service(&svc_ctx, ctx.user_uuid_unwrap(), team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(tags))) +} + +/// 更新标签 +pub async fn update_tag_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::tags::update_tag_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除标签 +pub async fn delete_tag_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::tags::delete_tag_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!(&svc_ctx, &ctx, "delete", "tag", payload.uuid, "删除标签").await; + + Ok(Response::success(Some("删除成功"), None)) +} + +// ============ Environments ============ + +/// 获取环境列表 +pub async fn get_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let (environments, total) = services::environments::get_environments_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(EnvironmentListResponse { + items: environments, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取环境详情 +pub async fn get_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let detail = services::environments::get_environment_detail_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + payload.uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(detail), + )) +} + +/// 批量获取环境详情 +pub async fn batch_get_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let mut results = std::collections::HashMap::new(); + + for uuid in payload.uuids { + // 对每个 UUID 尝试获取环境详情,失败则跳过 + let detail = async { + services::environments::get_environment_detail_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + uuid, + ) + .await + .ok() + } + .await; + + // 只有成功获取的环境才加入结果 + if let Some(detail) = detail { + results.insert(uuid.to_string(), detail); + } + } + + Ok(Response::success(Some("获取成功"), Some(results))) +} + +/// 创建环境 +pub async fn create_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let env_uuid = services::environments::create_environment_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "environment", + env_uuid, + &payload.name, + "创建环境" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: env_uuid }), + )) +} + +/// 批量创建环境 +pub async fn batch_create_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let env_uuids = services::environments::batch_create_environments_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let count = env_uuids.len(); + let responses: Vec = + env_uuids.into_iter().map(|uuid| CreateResponse { uuid }).collect(); + + audit_log!( + &svc_ctx, + &ctx, + "batch_create", + "environment", + &format!("批量创建 {} 个环境", count) + ) + .await; + + Ok(Response::success(Some("批量创建成功"), Some(responses))) +} + +/// 更新环境 +pub async fn update_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + services::environments::update_environment_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除环境 +pub async fn delete_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + services::environments::delete_environment_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + payload.uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "delete", + "environment", + payload.uuid, + "删除环境" + ) + .await; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 批量删除环境 +pub async fn batch_delete_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let count = services::environments::batch_delete_environments_service(&svc_ctx, &payload.uuids) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "batch_delete", + "environment", + &format!("批量删除 {} 个环境", count) + ) + .await; + + Ok(Response::success(Some("删除成功"), Some(count))) +} + +// ============ Recycle Bin ============ + +/// 查询回收站环境列表 +pub async fn get_recycle_bin_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let (environments, total) = services::environments::get_recycle_bin_environments_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + None, + Some(EnvironmentListResponse { + items: environments, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 恢复环境 +pub async fn restore_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::restore_environment_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "restore", + "environment", + payload.uuid, + "恢复环境" + ) + .await; + + Ok(Response::success(Some("恢复成功"), None)) +} + +/// 批量恢复环境 +pub async fn batch_restore_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let count = services::environments::batch_restore_environments_service(&svc_ctx, &payload.uuids) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "batch_restore", + "environment", + &format!("批量恢复 {} 个环境", count) + ) + .await; + + Ok(Response::success(Some("恢复成功"), Some(count))) +} + +/// 永久删除环境 +pub async fn permanent_delete_environment_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + services::environments::permanent_delete_environment_service(&svc_ctx, payload.uuid, workspace_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "permanent_delete", + "environment", + payload.uuid, + "永久删除环境" + ) + .await; + + Ok(Response::success(Some("永久删除成功"), None)) +} + +/// 批量永久删除环境 +pub async fn batch_permanent_delete_environments_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let count = services::environments::batch_permanent_delete_environments_service( + &svc_ctx, + &payload.uuids, + workspace_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "batch_permanent_delete", + "environment", + &format!("批量永久删除 {} 个环境", count) + ) + .await; + + Ok(Response::success(Some("永久删除成功"), Some(count))) +} + +/// 设置环境代理 +pub async fn set_proxy_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::set_environment_proxy_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("设置成功"), None)) +} + +/// 分配标签 +pub async fn assign_tags_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::assign_tags_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("分配成功"), None)) +} + +/// 移除标签 +pub async fn remove_tag_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::remove_tag_service(&svc_ctx, payload.uuid, payload.tag_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("移除成功"), None)) +} + +/// 移动到分组 +pub async fn move_to_group_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::move_to_group_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("移动成功"), None)) +} + +/// 批量移动到分组 +pub async fn batch_move_to_group_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::batch_move_to_group_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("移动成功"), None)) +} + +/// 设置环境账号 +pub async fn set_environment_accounts_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::accounts::set_environment_accounts_service( + &svc_ctx, + payload.uuid, + &payload.account_uuids, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("设置成功"), None)) +} + +/// 批量分配标签 +pub async fn batch_assign_tags_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::batch_assign_tags_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("分配成功"), None)) +} + +/// 批量移除标签 +pub async fn batch_remove_tags_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::batch_remove_tags_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("移除成功"), None)) +} + +// ============ Environment URLs ============ + +/// 添加环境 URL +pub async fn add_environment_url_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let id = services::environments::add_environment_url_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("添加成功"), Some(IdResponse { id }))) +} + +/// 获取环境 URL 列表 +pub async fn get_environment_urls_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result> { + let urls = services::environments::get_environment_urls_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(urls))) +} + +/// 删除环境 URL +pub async fn delete_environment_url_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::delete_environment_url_service(&svc_ctx, payload.id) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 清空环境 URL +pub async fn clear_environment_urls_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let count = + services::environments::clear_environment_urls_service(&svc_ctx, payload.environment_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("清空成功"), Some(count))) +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie +pub async fn add_environment_cookie_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let id = services::environments::add_environment_cookie_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("添加成功"), Some(IdResponse { id }))) +} + +/// 获取环境 Cookie 列表 +pub async fn get_environment_cookies_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result> { + let cookies = services::environments::get_environment_cookies_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(cookies))) +} + +/// 删除环境 Cookie +pub async fn delete_environment_cookie_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::environments::delete_environment_cookie_service(&svc_ctx, payload.id) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 清空环境 Cookies +pub async fn clear_environment_cookies_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let count = services::environments::clear_environment_cookies_service( + &svc_ctx, + payload.environment_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("清空成功"), Some(count))) +} diff --git a/server/src/handlers/extensions.rs b/server/src/handlers/extensions.rs new file mode 100644 index 00000000..a8993546 --- /dev/null +++ b/server/src/handlers/extensions.rs @@ -0,0 +1,242 @@ +use axum::extract::{Extension, State}; + +use crate::dto::ExtensionDto; +use crate::entitys::{ + BatchUpdateExtensionsRequest, BatchUpdateResponse, ExtensionIdRequest, ExtensionsListResponse, + GetInstalledExtensionsRequest, InstallExtensionRequest, InstalledExtensionsResponse, + ListExtensionsRequest, ToggleExtensionRequest, UninstallExtensionRequest, UpdateExtensionRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取扩展列表 +pub async fn get_extensions_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let (items, total) = services::extensions::get_extensions_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ExtensionsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取扩展详情 +pub async fn get_extension_detail_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let extension = services::extensions::get_extension_service(&svc_ctx, &payload.extension_id) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(extension))) +} + +/// 获取扩展分类 +pub async fn get_extension_categories_handler( + State(svc_ctx): State, +) -> Result> { + let categories = services::extensions::get_extension_categories_service(&svc_ctx) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(categories))) +} + +/// 获取已安装的扩展 +pub async fn get_installed_extensions_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (user_extensions, team_extensions) = match payload.scope.as_str() { + "user" | "personal" => { + let user_extensions = services::extensions::get_user_installed_extensions_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + (user_extensions, Vec::new()) + } + "team" => { + let team_uuid = team_uuid.ok_or_else(|| Response::fail(Some("未指定团队")))?; + let team_extensions = + services::extensions::get_team_installed_extensions_service(&svc_ctx, team_uuid, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + (Vec::new(), team_extensions) + } + _ => { + // "all" 或其他值,返回全部 + let user_extensions = services::extensions::get_user_installed_extensions_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + let team_extensions = if let Some(team_uuid) = team_uuid { + services::extensions::get_team_installed_extensions_service(&svc_ctx, team_uuid, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + } else { + Vec::new() + }; + (user_extensions, team_extensions) + } + }; + + Ok(Response::success( + Some("获取成功"), + Some(InstalledExtensionsResponse { + user_extensions, + team_extensions, + }), + )) +} + +/// 安装扩展 +pub async fn install_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + services::extensions::install_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("安装成功"), None)) +} + +/// 卸载扩展 +pub async fn uninstall_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + services::extensions::uninstall_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("卸载成功"), None)) +} + +/// 更新扩展 +pub async fn update_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::extensions::update_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload.extension_id, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 批量更新扩展 +pub async fn batch_update_extensions_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let updated = services::extensions::batch_update_extensions_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload.extension_ids, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("批量更新完成"), + Some(BatchUpdateResponse { updated }), + )) +} + +/// 禁用扩展(用户级别) +pub async fn disable_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("未指定团队")))?; + + services::extensions::disable_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload.extension_id, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("禁用成功"), None)) +} + +/// 启用扩展(用户级别) +pub async fn enable_extension_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("未指定团队")))?; + + services::extensions::enable_extension_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload.extension_id, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("启用成功"), None)) +} diff --git a/server/src/handlers/group_permissions.rs b/server/src/handlers/group_permissions.rs new file mode 100644 index 00000000..4de53a71 --- /dev/null +++ b/server/src/handlers/group_permissions.rs @@ -0,0 +1,138 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + CheckGroupPermissionRequest, GrantGroupPermissionRequest, ListUserGroupPermissionsRequest, + RevokeGroupPermissionRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 授予分组权限 +pub async fn grant_group_permission_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::group_permissions::grant_group_permission_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "grant", + "group_permission", + payload.group_uuid, + &payload.permission_type, + "授予分组权限" + ) + .await; + + Ok(Response::success(Some("授权成功"), None)) +} + +/// 撤销分组权限 +pub async fn revoke_group_permission_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::group_permissions::revoke_group_permission_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "revoke", + "group_permission", + payload.group_uuid, + "", + "撤销分组权限" + ) + .await; + + Ok(Response::success(Some("撤销成功"), None)) +} + +/// 检查分组权限 +pub async fn check_group_permission_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let has_permission = + services::group_permissions::check_group_permission_service(&svc_ctx, workspace_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + None, + Some(crate::entitys::CheckPermissionResponse { + has_permission, + permission_type: if has_permission { + Some(payload.permission_type.clone()) + } else { + None + }, + }), + )) +} + +/// 查询用户的分组权限列表 +pub async fn list_user_group_permissions_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result { + let permissions = + services::group_permissions::list_user_group_permissions_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let total = permissions.len() as i64; + + // 转换为详情 DTO(包含用户信息) + let mut permission_details = vec![]; + for perm in permissions { + let user_info = crate::models::user::fetch_user_info_by_uuid(&svc_ctx.db, perm.user_uuid) + .await + .ok() + .flatten(); + + permission_details.push(crate::dto::GroupMemberPermissionDetailDto { + group_uuid: perm.group_uuid, + workspace_uuid: perm.workspace_uuid, + team_uuid: perm.team_uuid, + user_uuid: perm.user_uuid, + permission_type: perm.permission_type, + granted_by: perm.granted_by, + user_name: user_info.as_ref().and_then(|u| u.nickname.clone()), + user_email: user_info.as_ref().map(|u| u.email.clone()), + created_at: perm.created_at, + updated_at: perm.updated_at, + }); + } + + Ok(Response::success( + None, + Some(crate::entitys::GroupPermissionListResponse { + items: permission_details, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} diff --git a/server/src/handlers/health.rs b/server/src/handlers/health.rs new file mode 100644 index 00000000..03e05d60 --- /dev/null +++ b/server/src/handlers/health.rs @@ -0,0 +1,15 @@ +use chrono::Utc; +use serde_json::{Value, json}; + +use crate::utils::{Response, Result}; + +/// 健康检查 +pub async fn health_check_handler() -> Result { + Ok(Response::success( + Some("服务正常"), + Some(json!({ + "status": "ok", + "timestamp": Utc::now().to_rfc3339(), + })), + )) +} diff --git a/server/src/handlers/local_api.rs b/server/src/handlers/local_api.rs new file mode 100644 index 00000000..45780601 --- /dev/null +++ b/server/src/handlers/local_api.rs @@ -0,0 +1,62 @@ +use axum::extract::{Extension, State}; + +use crate::entitys::{ + GetLocalApiConfigRequest, ResetLocalApiKeyRequest, UpdateLocalApiConfigRequest, + ValidateLocalApiKeyRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +pub async fn get_local_api_config_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(_payload): Json, +) -> Result { + let config = + services::local_api::get_local_api_config_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(None, Some(config))) +} + +pub async fn update_local_api_config_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let config = services::local_api::update_local_api_config_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), Some(config))) +} + +pub async fn reset_local_api_key_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(_payload): Json, +) -> Result { + let result = services::local_api::reset_local_api_key_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("重置成功"), Some(result))) +} + +pub async fn validate_local_api_key_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let result = services::local_api::validate_local_api_key_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(None, Some(result))) +} diff --git a/server/src/handlers/messages.rs b/server/src/handlers/messages.rs new file mode 100644 index 00000000..d63010b3 --- /dev/null +++ b/server/src/handlers/messages.rs @@ -0,0 +1,133 @@ +use axum::{extract::Extension, extract::State}; + +use crate::entitys::{ + BatchMarkReadRequest, CreateMessageRequest, HandleMessageRequest, ListMessagesRequest, + MarkMessageReadRequest, MessageListResponse, MessageStatsResponse, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; +use sqlx; + +/// 创建消息 +pub async fn create_message_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let message_uuid = services::messages::create_message_service( + &svc_ctx, + Some(ctx.user_uuid_unwrap()), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("消息创建成功"), + Some(crate::entitys::CreateResponse { uuid: message_uuid }), + )) +} + +/// 获取用户消息列表 +pub async fn get_user_messages_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let response = + services::messages::get_user_messages_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(response))) +} + +/// 标记消息为已读 +pub async fn mark_message_read_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::messages::mark_message_read_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("标记成功"), None)) +} + +/// 批量标记消息为已读 +pub async fn batch_mark_messages_read_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::messages::batch_mark_messages_read_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("批量标记成功"), None)) +} + +/// 处理消息(接受/拒绝) +pub async fn handle_message_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::messages::handle_message_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("处理成功"), None)) +} + +/// 获取用户消息统计 +pub async fn get_user_message_stats_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let response = + services::messages::get_user_message_stats_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(response))) +} + +/// 删除消息 +pub async fn delete_message_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + // 验证消息是否属于当前用户(通过查询 user_messages 表) + let user_message_exists: bool = sqlx::query_scalar( + r#" + SELECT EXISTS( + SELECT 1 FROM user_messages + WHERE message_uuid = $1 AND user_uuid = $2 + ) + "#, + ) + .bind(payload.uuid) + .bind(ctx.user_uuid_unwrap()) + .fetch_one(&svc_ctx.db) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + if !user_message_exists { + return Err(Response::fail(Some("消息不存在或无权限"))); + } + + services::messages::delete_message_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} diff --git a/server/src/handlers/preferences.rs b/server/src/handlers/preferences.rs new file mode 100644 index 00000000..2c797d89 --- /dev/null +++ b/server/src/handlers/preferences.rs @@ -0,0 +1,38 @@ +use axum::extract::{Extension, State}; + +use crate::dto::UserPreferenceDto; +use crate::entitys::settings::UpdatePreferencesRequest; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取用户偏好设置 +pub async fn get_preferences_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let preferences = + services::preferences::get_preferences_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(preferences))) +} + +/// 更新用户偏好设置 +pub async fn update_preferences_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let preferences = services::preferences::update_preferences_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), Some(preferences))) +} diff --git a/server/src/handlers/proxies.rs b/server/src/handlers/proxies.rs new file mode 100644 index 00000000..d0ecd7e6 --- /dev/null +++ b/server/src/handlers/proxies.rs @@ -0,0 +1,174 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + BatchImportProxiesRequest, BatchImportResponse, BatchUuidRequest, CreateProxyRequest, + CreateResponse, ListProxiesRequest, ProxyListResponse, UpdateProxyRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取代理列表 +pub async fn get_proxies_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx + .current_workspace_uuid + .ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let (proxies, total) = + services::proxies::get_proxies_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + ctx.current_team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ProxyListResponse { + items: proxies, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取代理详情 +pub async fn get_proxy_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let proxy = services::proxies::get_proxy_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(proxy))) +} + +/// 创建代理 +pub async fn create_proxy_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let proxy_uuid = + services::proxies::create_proxy_service(&svc_ctx, ctx.user_uuid_unwrap(), workspace_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "proxy", + proxy_uuid, + &payload.name, + "创建代理" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: proxy_uuid }), + )) +} + +/// 更新代理 +pub async fn update_proxy_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::proxies::update_proxy_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除代理 +pub async fn delete_proxy_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::proxies::delete_proxy_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!(&svc_ctx, &ctx, "delete", "proxy", payload.uuid, "删除代理").await; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 批量删除代理 +pub async fn batch_delete_proxies_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let count = services::proxies::batch_delete_proxies_service(&svc_ctx, &payload.uuids) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "batch_delete", + "proxy", + &format!("批量删除 {} 个代理", count) + ) + .await; + + Ok(Response::success(Some("删除成功"), Some(count))) +} + +/// 批量导入代理 +pub async fn batch_import_proxies_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + // 获取工作空间 UUID(从请求或当前工作空间) + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + + let result = services::proxies::batch_import_proxies_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "batch_import", + "proxy", + &format!( + "批量导入代理: 成功 {} 个, 失败 {} 个", + result.success_count, result.failed_count + ) + ) + .await; + + Ok(Response::success( + Some("导入完成"), + Some(BatchImportResponse { + success_count: result.success_count, + failed_count: result.failed_count, + errors: result.errors, + }), + )) +} diff --git a/server/src/handlers/proxy_visibility.rs b/server/src/handlers/proxy_visibility.rs new file mode 100644 index 00000000..a27201f9 --- /dev/null +++ b/server/src/handlers/proxy_visibility.rs @@ -0,0 +1,150 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + BatchSetProxyVisibleRequest, ListProxyVisibleTeamsRequest, ListVisibleProxiesRequest, + RemoveProxyVisibleRequest, SetProxyVisibleRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 设置代理对团队可见 +pub async fn set_proxy_visible_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::proxy_visibility::set_proxy_visible_to_team_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "update", + "proxy_visibility", + payload.proxy_uuid, + "", + "设置代理可见性" + ) + .await; + + Ok(Response::success(Some("设置成功"), None)) +} + +/// 移除代理对团队的可见性 +pub async fn remove_proxy_visible_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::proxy_visibility::remove_proxy_visible_from_team_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "update", + "proxy_visibility", + payload.proxy_uuid, + "", + "移除代理可见性" + ) + .await; + + Ok(Response::success(Some("移除成功"), None)) +} + +/// 批量设置代理可见性 +pub async fn batch_set_proxy_visible_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::proxy_visibility::batch_set_proxy_visible_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "update", + "proxy_visibility", + payload.proxy_uuid, + "", + "批量设置代理可见性" + ) + .await; + + Ok(Response::success(Some("设置成功"), None)) +} + +/// 获取可见的代理列表 +pub async fn get_visible_proxies_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let proxies = services::proxy_visibility::get_visible_proxies_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + None, + Some(crate::entitys::VisibleProxyListResponse { items: proxies }), + )) +} + +/// 获取代理的可见团队列表 +pub async fn get_proxy_visible_teams_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result { + let teams = crate::models::fetch_visible_teams_by_proxy(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + // 转换为详情 DTO(包含团队名称) + let mut team_details = vec![]; + for team in teams { + let team_info = crate::models::fetch_team_by_uuid(&svc_ctx.db, team.team_uuid) + .await + .ok() + .flatten(); + + team_details.push(crate::dto::ProxyVisibleTeamDetailDto { + proxy_uuid: team.proxy_uuid, + workspace_uuid: team.workspace_uuid, + team_uuid: team.team_uuid, + team_name: team_info.map(|t| t.name), + created_at: team.created_at, + }); + } + + Ok(Response::success( + None, + Some(crate::entitys::ProxyVisibleTeamListResponse { + items: team_details, + }), + )) +} diff --git a/server/src/handlers/referral.rs b/server/src/handlers/referral.rs new file mode 100644 index 00000000..d1eb8fa0 --- /dev/null +++ b/server/src/handlers/referral.rs @@ -0,0 +1,210 @@ +use axum::extract::{Extension, State}; + +use crate::dto::{RedeemOptionDto, ReferralLinkTierDto, UserReferralPointsDto}; +use crate::entitys::{ + ListReferralRewardsRequest, ListReferredUsersRequest, Pagination, RedeemPointsRequest, + RedeemRecordsListResponse, RedeemResponse, ReferralDashboardResponse, + ReferralLinksListResponse, ReferralPlanSummaryResponse, ReferralRewardsListResponse, + ReferralStatsResponse, ReferredUsersListResponse, SwitchReferralLinkRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取推广统计 +pub async fn get_referral_stats_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let stats = services::referral::get_referral_stats_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(stats))) +} + +/// 获取推广看板聚合数据 +pub async fn get_referral_dashboard_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let dashboard = services::referral::get_referral_dashboard_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(dashboard))) +} + +/// 获取套餐页推广摘要信息 +pub async fn get_referral_plan_summary_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let summary = + services::referral::get_referral_plan_summary_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(summary))) +} + +/// 获取推广链接层级配置 +pub async fn get_referral_tiers_handler( + State(svc_ctx): State, +) -> Result> { + let tiers = services::referral::get_referral_tiers_service(&svc_ctx) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(tiers))) +} + +/// 获取推广链接列表 +pub async fn get_referral_links_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let (items, current_link) = + services::referral::get_referral_links_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ReferralLinksListResponse { + items, + current_link, + }), + )) +} + +/// 切换当前推广链接 +pub async fn switch_referral_link_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::referral::switch_referral_link_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + payload.link_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("切换成功"), None)) +} + +/// 获取奖励记录列表 +pub async fn get_referral_rewards_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = services::referral::get_referral_rewards_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ReferralRewardsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取被邀请用户列表 +pub async fn get_referred_users_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = + services::referral::get_referred_users_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(ReferredUsersListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取用户积分 +pub async fn get_user_points_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let points = services::referral::get_user_points_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(points))) +} + +/// 获取兑换选项 +pub async fn get_redeem_options_handler( + State(svc_ctx): State, +) -> Result> { + let options = services::referral::get_redeem_options_service(&svc_ctx) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(options))) +} + +/// 执行积分兑换 +pub async fn redeem_points_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (record_uuid, points_used, value) = + services::referral::redeem_points_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("兑换成功"), + Some(RedeemResponse { + record_uuid, + points_used, + value, + }), + )) +} + +/// 获取兑换记录 +pub async fn get_redeem_records_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let (items, total) = + services::referral::get_redeem_records_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(RedeemRecordsListResponse { + items, + total, + page: payload.page, + page_size: payload.page_size, + }), + )) +} diff --git a/server/src/handlers/rpa.rs b/server/src/handlers/rpa.rs new file mode 100644 index 00000000..53475067 --- /dev/null +++ b/server/src/handlers/rpa.rs @@ -0,0 +1,144 @@ +use axum::extract::{Extension, State}; + +use crate::entitys::{ + BatchUuidRequest, CreateResponse, CreateRpaTaskRequest, DuplicateRpaTaskRequest, + ExportRpaTaskRequest, ExportRpaTaskResponse, ListRpaTasksRequest, RpaTaskDetailResponse, + RpaTaskListResponse, UpdateRpaTaskRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +pub async fn get_rpa_tasks_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (items, total) = + services::rpa::get_rpa_tasks_service(&svc_ctx, ctx.user_uuid_unwrap(), team_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("ok"), + Some(RpaTaskListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +pub async fn get_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let (task, steps, environment_uuids) = + services::rpa::get_rpa_task_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("ok"), + Some(RpaTaskDetailResponse { + task, + steps, + environment_uuids, + }), + )) +} + +pub async fn create_rpa_task_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let uuid = services::rpa::create_rpa_task_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("created"), Some(CreateResponse { uuid }))) +} + +pub async fn update_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::rpa::update_rpa_task_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("updated"), None)) +} + +pub async fn delete_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::rpa::delete_rpa_task_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("deleted"), None)) +} + +pub async fn batch_delete_rpa_tasks_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::rpa::batch_delete_rpa_tasks_service(&svc_ctx, &payload.uuids) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("deleted"), None)) +} + +pub async fn duplicate_rpa_task_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let uuid = services::rpa::duplicate_rpa_task_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("duplicated"), Some(CreateResponse { uuid }))) +} + +pub async fn export_rpa_task_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let (content, filename) = services::rpa::export_rpa_task_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("exported"), + Some(ExportRpaTaskResponse { content, filename }), + )) +} diff --git a/server/src/handlers/secret.rs b/server/src/handlers/secret.rs new file mode 100644 index 00000000..73faffdf --- /dev/null +++ b/server/src/handlers/secret.rs @@ -0,0 +1,15 @@ +use axum::http::StatusCode; +use axum::response::IntoResponse; + +use crate::utils::get_rsa_secret_instance; + +/// 获取服务器公钥 +/// +/// 返回服务器的 RSA 公钥(PEM 格式),用于客户端加密数据 +/// +/// # Returns +/// 返回纯文本格式的 PEM 公钥字符串 +pub async fn get_public_key_handler() -> impl IntoResponse { + let public_key = get_rsa_secret_instance().get_public_key(); + (StatusCode::OK, public_key).into_response() +} diff --git a/server/src/handlers/teams.rs b/server/src/handlers/teams.rs new file mode 100644 index 00000000..9cf35ccb --- /dev/null +++ b/server/src/handlers/teams.rs @@ -0,0 +1,364 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + AcceptInvitationRequest, AcceptInvitationResponse, CreateResponse, CreateTeamRequest, + InviteMemberRequest, InviteResponse, ListTeamMembersRequest, MemberListResponse, + RejectInvitationRequest, RemoveMemberRequest, SwitchTeamRequest, TeamItem, TeamListResponse, + UpdateMemberRoleRequest, UpdateTeamRequest, UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 创建团队 +pub async fn create_team_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = + services::teams::create_team_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "team", + team_uuid, + &payload.name, + "创建团队" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: team_uuid }), + )) +} + +/// 获取用户的所有团队 +pub async fn get_my_teams_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let teams = services::teams::get_user_teams_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let current_team_uuid = + services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let mut team_items: Vec = vec![]; + + for t in &teams { + // 获取用户在该团队的角色 + let role = if t.owner_uuid == ctx.user_uuid_unwrap() { + "owner".to_string() + } else { + crate::models::fetch_team_member(&svc_ctx.db, workspace_uuid, t.uuid, ctx.user_uuid_unwrap()) + .await + .ok() + .flatten() + .map(|m| m.role) + .unwrap_or_else(|| "member".to_string()) + }; + + // 获取成员数量(不使用筛选,统计所有活跃成员) + let members_count = + crate::models::fetch_team_member_count(&svc_ctx.db, t.uuid, None, None, None) + .await + .unwrap_or(0); + + team_items.push(TeamItem { + uuid: t.uuid, + name: t.name.clone(), + description: t.description.clone(), + role, + members_count, + is_current: current_team_uuid == Some(t.uuid), + }); + } + + Ok(Response::success( + Some("获取成功"), + Some(TeamListResponse { + current_team_uuid, + teams: team_items, + }), + )) +} + +/// 切换团队 +pub async fn switch_team_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + services::teams::switch_team_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("切换成功"), None)) +} + +/// 获取团队详情 +pub async fn get_team_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let team = services::teams::get_team_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(team))) +} + +/// 更新团队信息 +pub async fn update_team_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + services::teams::update_team_service(&svc_ctx, workspace_uuid, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 获取团队成员列表 +pub async fn get_team_members_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + // 获取用户当前团队 + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let (members, total) = services::teams::get_team_members_service(&svc_ctx, team_uuid, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(MemberListResponse { + items: members, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 邀请成员 +pub async fn invite_member_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let invitation_uuid = services::teams::invite_member_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "invite", + "team_member", + &format!("邀请成员: {}", payload.email) + ) + .await; + + Ok(Response::success( + Some("邀请已发送"), + Some(InviteResponse { invitation_uuid }), + )) +} + +/// 更新成员角色 +pub async fn update_member_role_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let member = services::teams::update_member_role_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), Some(member))) +} + +/// 移除成员 +pub async fn remove_member_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + services::teams::remove_member_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + payload.member_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "remove", + "team_member", + payload.member_uuid, + "移除成员" + ) + .await; + + Ok(Response::success(Some("移除成功"), None)) +} + +/// 获取待处理邀请列表 +pub async fn get_pending_invitations_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result> { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let invitations = services::teams::get_pending_invitations_service(&svc_ctx, team_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(invitations))) +} + +/// 取消邀请 +pub async fn cancel_invitation_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::teams::cancel_invitation_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("邀请已取消"), None)) +} + +/// 接受邀请 +pub async fn accept_invitation_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::accept_invitation_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + &payload.token, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "accept_invitation", + "team", + team_uuid, + "接受团队邀请" + ) + .await; + + Ok(Response::success( + Some("已加入团队"), + Some(AcceptInvitationResponse { team_uuid }), + )) +} + +/// 拒绝邀请 +pub async fn reject_invitation_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::teams::reject_invitation_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload.token) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!(&svc_ctx, &ctx, "reject_invitation", "team", "拒绝团队邀请").await; + + Ok(Response::success(Some("已拒绝邀请"), None)) +} + +/// 退出团队 +pub async fn leave_team_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result<()> { + // 获取当前团队 + let current_team_uuid = + services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))? + .ok_or_else(|| Response::fail(Some("当前没有选择团队")))?; + + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + services::teams::leave_team_service(&svc_ctx, workspace_uuid, current_team_uuid, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "leave", + "team", + current_team_uuid, + "退出团队" + ) + .await; + + Ok(Response::success(Some("已退出团队"), None)) +} diff --git a/server/src/handlers/templates.rs b/server/src/handlers/templates.rs new file mode 100644 index 00000000..de4ca176 --- /dev/null +++ b/server/src/handlers/templates.rs @@ -0,0 +1,157 @@ +use axum::{extract::Extension, extract::State}; + +use crate::entitys::{ + CreateFromTemplateRequest, CreateResponse, CreateTemplateRequest, GetTemplateRequest, + ListTemplatesRequest, TemplateDetailResponse, TemplateListResponse, UpdateTemplateRequest, + UuidRequest, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取模板列表 +pub async fn get_templates_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let (templates, total) = services::templates::get_templates_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + payload.is_public, + payload.pagination.page, + payload.pagination.page_size, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("获取成功"), + Some(TemplateListResponse { + items: templates, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }), + )) +} + +/// 获取模板详情 +pub async fn get_template_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let result = services::templates::get_template_service( + &svc_ctx, + payload.uuid, + payload.for_create.unwrap_or(false), + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("获取成功"), Some(result))) +} + +/// 创建模板 +pub async fn create_template_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let team_uuid = services::teams::get_current_team_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + let template_uuid = services::templates::create_template_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { + uuid: template_uuid, + }), + )) +} + +/// 更新模板 +pub async fn update_template_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::templates::update_template_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除模板 +pub async fn delete_template_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + services::templates::delete_template_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 应用模板到环境(更新环境配置为模板配置) +pub async fn apply_template_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + services::templates::apply_template_service( + &svc_ctx, + workspace_uuid, + team_uuid, + ctx.user_uuid_unwrap(), + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("应用成功"), None)) +} + +/// 从模板创建环境 +pub async fn create_from_template_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = ctx.current_workspace_uuid.ok_or_else(|| Response::fail(Some("请先选择工作空间")))?; + let team_uuid = ctx.current_team_uuid.ok_or_else(|| Response::fail(Some("请先选择团队")))?; + + let env_uuid = services::templates::create_from_template_service( + &svc_ctx, + ctx.user_uuid_unwrap(), + workspace_uuid, + team_uuid, + &payload, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { uuid: env_uuid }), + )) +} diff --git a/server/src/handlers/time.rs b/server/src/handlers/time.rs new file mode 100644 index 00000000..cc77cd95 --- /dev/null +++ b/server/src/handlers/time.rs @@ -0,0 +1,10 @@ +use crate::{ + services::now_service, + utils::{Response, Result}, +}; + +pub async fn now_handle() -> Result { + let now = now_service(); + + Ok(Response::::success(Some("获取时间成功"), Some(now))) +} diff --git a/server/src/handlers/users.rs b/server/src/handlers/users.rs new file mode 100644 index 00000000..59544b1b --- /dev/null +++ b/server/src/handlers/users.rs @@ -0,0 +1,151 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + LoginRequest, LoginResponse, RegisterRequest, RegisterResponse, ResetPasswordRequest, + SendCodeRequest, UpdatePasswordRequest, UpdateUserRequest, UserResponse, + VerifyPasswordRequest, VerifyPasswordResponse, +}; +use crate::services::audit::log_audit_anonymous; +use crate::services::users::{ + get_current_user_service, login_service, refresh_token_service, register_service, + reset_password_service, send_verification_code_service, update_password_service, + update_user_service, verify_password_service, +}; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 用户注册处理 +pub async fn register_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = register_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + // 记录审计日志(注册时使用返回的 user_uuid) + if let Some(ref user_info) = result.user_info { + log_audit_anonymous( + &svc_ctx, + &ctx, + user_info.uuid, + "register", + "user", + "用户注册", + ) + .await; + } + + Ok(Response::success(Some("注册成功"), Some(result))) +} + +/// 用户登录处理 +pub async fn login_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = login_service(&svc_ctx, payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + // 记录审计日志(登录时使用返回的 user_uuid) + if let Some(ref user_info) = result.user_info { + log_audit_anonymous(&svc_ctx, &ctx, user_info.uuid, "login", "user", "用户登录").await; + } + + Ok(Response::success(Some("登录成功"), Some(result))) +} + +/// 刷新 Token 处理 +pub async fn refresh_token_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result { + let result = refresh_token_service(&svc_ctx, &payload.refresh_token) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("刷新成功"), Some(result))) +} + +/// 获取当前用户信息处理 +pub async fn get_current_user_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let user_info = get_current_user_service(&svc_ctx, ctx.user_uuid_unwrap()) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("获取成功"), Some(user_info))) +} + +/// 更新用户信息处理 +pub async fn update_user_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + update_user_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 修改密码处理 +pub async fn update_password_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + update_password_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + // 记录审计日志 + audit_log!(&svc_ctx, &ctx, "update_password", "user", "修改密码").await; + + Ok(Response::success(Some("修改成功"), None)) +} + +/// 校验当前用户密码处理 +pub async fn verify_password_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let result = verify_password_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("校验成功"), Some(result))) +} + +/// 重置密码处理 +pub async fn reset_password_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + reset_password_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("重置成功"), None)) +} + +/// 发送验证码处理 +pub async fn send_code_handler( + State(svc_ctx): State, + Json(payload): Json, +) -> Result<()> { + send_verification_code_service(&svc_ctx, &payload.email, &payload.r#type) + .await + .map_err(|e| Response::fail(Some(&e.to_string())))?; + + Ok(Response::success(Some("发送成功"), None)) +} diff --git a/server/src/handlers/workspace_quotas.rs b/server/src/handlers/workspace_quotas.rs new file mode 100644 index 00000000..9f795833 --- /dev/null +++ b/server/src/handlers/workspace_quotas.rs @@ -0,0 +1,43 @@ +use axum::{extract::Extension, extract::State}; + +use crate::entitys::{GetWorkspaceQuotaRequest, UpdateQuotaUsageRequest}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 获取工作空间配额 +pub async fn get_workspace_quota_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(mut payload): Json, +) -> Result { + // 如果没有传递 workspace_uuid,则从 RequestContext 中获取 + if payload.workspace_uuid.is_none() { + payload.workspace_uuid = ctx.current_workspace_uuid; + } + + // 验证 workspace_uuid 是否存在 + if payload.workspace_uuid.is_none() { + return Err(Response::fail(Some("未提供工作空间 UUID"))); + } + + let quota = services::workspace_quotas::get_workspace_quota_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(None, Some(quota))) +} + +/// 更新配额使用情况 +pub async fn update_quota_usage_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::workspace_quotas::update_quota_usage_service(&svc_ctx, &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("更新成功"), None)) +} diff --git a/server/src/handlers/workspaces.rs b/server/src/handlers/workspaces.rs new file mode 100644 index 00000000..2d22ce5b --- /dev/null +++ b/server/src/handlers/workspaces.rs @@ -0,0 +1,179 @@ +use axum::{extract::Extension, extract::State}; + +use crate::audit_log; +use crate::entitys::{ + CreateResponse, CreateWorkspaceRequest, SwitchWorkspaceRequest, UpdateWorkspaceRequest, + WorkspaceItem, WorkspaceListResponse, +}; +use crate::services; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; +use crate::utils::{Json, Response, Result}; + +/// 创建工作空间 +pub async fn create_workspace_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace_uuid = + services::workspaces::create_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "create", + "workspace", + workspace_uuid, + &payload.name, + "创建工作空间" + ) + .await; + + Ok(Response::success( + Some("创建成功"), + Some(CreateResponse { + uuid: workspace_uuid, + }), + )) +} + +/// 获取用户的所有工作空间 +pub async fn get_my_workspaces_handler( + State(svc_ctx): State, + Extension(ctx): Extension, +) -> Result { + let user_uuid = ctx.user_uuid_unwrap(); + let workspaces = services::workspaces::get_user_workspaces_service(&svc_ctx, user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + // 从 RequestContext 获取当前工作空间 UUID(已在 auth 中间件中设置) + let mut current_workspace_uuid = ctx.current_workspace_uuid; + + // 如果用户没有设置当前工作空间,但有工作空间列表,自动设置为第一个工作空间 + if current_workspace_uuid.is_none() && !workspaces.is_empty() { + let first_workspace_uuid = workspaces[0].uuid; + // 更新数据库 + if let Err(e) = crate::models::user::set_user_current_workspace( + &svc_ctx.db, + user_uuid, + first_workspace_uuid, + ) + .await + { + tracing::warn!("Failed to set default workspace: {}", e); + } else { + current_workspace_uuid = Some(first_workspace_uuid); + } + } + + let workspace_items: Vec = workspaces + .iter() + .map(|w| WorkspaceItem { + uuid: w.uuid, + name: w.name.clone(), + workspace_type: w.workspace_type.clone(), + is_current: current_workspace_uuid == Some(w.uuid), + }) + .collect(); + + Ok(Response::success( + None, + Some(WorkspaceListResponse { + current_workspace_uuid, + workspaces: workspace_items, + }), + )) +} + +/// 获取工作空间详情 +pub async fn get_workspace_handler( + State(svc_ctx): State, + Extension(_ctx): Extension, + Json(payload): Json, +) -> Result { + let workspace = services::workspaces::get_workspace_service(&svc_ctx, payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(None, Some(workspace))) +} + +/// 更新工作空间 +pub async fn update_workspace_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::workspaces::update_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), &payload) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "update", + "workspace", + payload.uuid, + &payload.name.as_deref().unwrap_or(""), + "更新工作空间" + ) + .await; + + Ok(Response::success(Some("更新成功"), None)) +} + +/// 删除工作空间 +pub async fn delete_workspace_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + services::workspaces::delete_workspace_service(&svc_ctx, ctx.user_uuid_unwrap(), payload.uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + audit_log!( + &svc_ctx, + &ctx, + "delete", + "workspace", + payload.uuid, + "", + "删除工作空间" + ) + .await; + + Ok(Response::success(Some("删除成功"), None)) +} + +/// 切换工作空间 +pub async fn switch_workspace_handler( + State(svc_ctx): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result<()> { + let user_uuid = ctx.user_uuid_unwrap(); + + // 检查用户是否是工作空间所有者 + let is_owner = services::workspaces::check_workspace_owner_service( + &svc_ctx, + payload.workspace_uuid, + user_uuid, + ) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + if !is_owner { + return Err(Response::fail(Some("您不是该工作空间的所有者"))); + } + + services::workspaces::switch_workspace_service(&svc_ctx, payload.workspace_uuid, user_uuid) + .await + .map_err(|e| Response::fail(Some(&e)))?; + + Ok(Response::success(Some("切换成功"), None)) +} diff --git a/server/src/lib.rs b/server/src/lib.rs new file mode 100644 index 00000000..a5252431 --- /dev/null +++ b/server/src/lib.rs @@ -0,0 +1,25 @@ +use crate::utils::IConfig; + +pub mod app; +pub mod caches; +pub mod cli; +pub mod database; +pub mod dto; +pub mod entitys; +pub mod errors; +pub mod handlers; +pub mod middlewares; +pub mod models; +pub mod routes; +pub mod services; +pub mod state; +pub mod svc_ctx; +pub mod utils; + +pub use app::{serve, serve_on, serve_on_with_shutdown}; + +/// Initialize the response-encryption key used by the transitional HTTP API. +pub async fn init_encrypt_secret(config: &IConfig) { + let key_path = &config.app.encrypt_secret_location; + utils::init_rsa_secret(key_path).await; +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 00000000..12d75c16 --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,26 @@ +use clap::Parser; +use simprint_server::{ + cli::{Cli, Commands}, + serve, + utils::IConfig, +}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + let (config_path, command) = cli.command_or_default(); + let config = IConfig::build_by_filepath(&config_path).expect("failed to build config"); + + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_target(true) + .with_thread_ids(true) + .try_init() + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + + match command { + Commands::Serve => serve(config).await?, + } + + Ok(()) +} diff --git a/server/src/middlewares.rs b/server/src/middlewares.rs new file mode 100644 index 00000000..0530061f --- /dev/null +++ b/server/src/middlewares.rs @@ -0,0 +1,42 @@ +mod auth; +mod cors; +mod decrypt; +mod encrypt; +mod local_api_auth; +mod logger; +mod real_ip; + +pub use auth::*; +pub use cors::*; +pub use decrypt::*; +pub use encrypt::*; +pub use local_api_auth::*; +pub use logger::*; +pub use real_ip::*; + +use axum::extract::{MatchedPath, Request}; + +/// 获取请求路径和请求方法 +pub fn get_request_path_and_method(req: &Request) -> (&str, &str) { + let path = req + .extensions() + .get::() + .map_or_else(|| req.uri().path(), |path| path.as_str()); + + let method = req.method().as_str(); + + (method, path) +} + +/// 从完整路径中提取业务路径(去除 /api/v*/ 前缀,保留前导斜杠) +/// 例如:/api/v1/environments -> /environments +pub fn extract_resource_path(path: &str) -> &str { + // 匹配 /api/v{数字}/ 格式的前缀 + if let Some(stripped) = path.strip_prefix("/api/v") { + // 找到第一个 / 后的内容(包含斜杠) + if let Some(pos) = stripped.find('/') { + return &stripped[pos..]; + } + } + path +} diff --git a/server/src/middlewares/auth.rs b/server/src/middlewares/auth.rs new file mode 100644 index 00000000..6d40a9cb --- /dev/null +++ b/server/src/middlewares/auth.rs @@ -0,0 +1,138 @@ +use std::str::FromStr; + +use axum::{ + extract::{Request, State}, + http::{StatusCode, header}, + middleware::Next, + response::Response, +}; +use uuid::Uuid; + +use crate::{ + middlewares::{extract_resource_path, get_request_path_and_method}, + services, + state::{CurrentUser, RequestContext}, + svc_ctx::SvcCtx, +}; + +pub async fn auth( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let (resource_method, resource_path) = get_request_path_and_method(&req); + + // 转换为拥有所有权的 String,避免借用冲突 + let resource_method = resource_method.to_string(); + let resource_path = resource_path.to_string(); + + if resource_method.eq("OPTIONS") { + return Ok(next.run(req).await); + } + + if req + .extensions() + .get::() + .and_then(|ctx| ctx.current_user.as_ref()) + .is_some() + { + return Ok(next.run(req).await); + } + + if crate::middlewares::has_local_api_auth_headers(&req) + && !extract_resource_path(&resource_path).starts_with("/local-api") + { + return Ok(next.run(req).await); + } + + // 判断请求的是否在白名单,如果是白名单就直接允许访问。 + { + let combine = format!("{}+{}", resource_method, resource_path); + let whitelists = &state.config.app.route_whitelists; + + if whitelists.contains(&combine) { + return Ok(next.run(req).await); + } + } + + let auth_header = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|header| header.to_str().ok()) + .ok_or_else(|| StatusCode::UNAUTHORIZED)?; + + let token_str = if auth_header.starts_with("Bearer ") { + &auth_header[7..] + } else { + return Err(StatusCode::UNAUTHORIZED); + }; + + // 先判断token是否正常,如果正常再去与远程的匹配。匹配成功后返回uuid. + let secret = state.config.app.secret.as_bytes(); + let verify_token_response = services::verify_token_service(token_str, secret); + + if let Err(_e) = verify_token_response { + return Err(StatusCode::UNAUTHORIZED); + } + + let verify_token_response = verify_token_response.unwrap(); + + // TODO: 根据获取到的uuid可以获取到用户并调用检查权限的方法。 + // 2025-08-27 10:05:00: 权限检查可能移除, 该网关服务只面向用户 + let uuid = if let Ok(uuid) = Uuid::from_str(&verify_token_response) { + uuid + } else { + return Err(StatusCode::UNAUTHORIZED); + }; + + fill_authenticated_request_context(&state, &mut req, uuid, &resource_method, &resource_path) + .await; + + Ok(next.run(req).await) +} + +pub async fn fill_authenticated_request_context( + state: &SvcCtx, + req: &mut Request, + user_uuid: Uuid, + resource_method: &str, + resource_path: &str, +) { + if req.extensions().get::().is_none() { + req.extensions_mut().insert(RequestContext::default()); + } + + if let Some(ctx) = req.extensions_mut().get_mut::() { + let ip = ctx.ip_or_unknown().to_string(); + tracing::info!("auth user uuid: {}, ip: {}", user_uuid, ip); + + ctx.current_user = Some(CurrentUser { user_uuid }); + ctx.current_team_uuid = None; + ctx.current_workspace_uuid = None; + + let business_path = extract_resource_path(resource_path); + ctx.resource_identifier = Some(format!("{}+{}", resource_method, business_path)); + } + + let user_info = crate::models::user::fetch_user_info_by_uuid(&state.db, user_uuid) + .await + .ok() + .flatten(); + + if let Some(ctx) = req.extensions_mut().get_mut::() { + if let Some(user_info) = user_info { + ctx.current_team_uuid = user_info.current_team_uuid; + ctx.current_workspace_uuid = if let Some(ws_uuid) = user_info.current_workspace_uuid { + Some(ws_uuid) + } else if let Some(team_uuid) = user_info.current_team_uuid { + crate::models::teams::fetch_team_by_uuid(&state.db, team_uuid) + .await + .ok() + .flatten() + .map(|team| team.workspace_uuid) + } else { + None + }; + } + } +} diff --git a/server/src/middlewares/cors.rs b/server/src/middlewares/cors.rs new file mode 100644 index 00000000..b409a28f --- /dev/null +++ b/server/src/middlewares/cors.rs @@ -0,0 +1,28 @@ +use axum::http::{ + HeaderName, Method, + header::{self}, +}; +use tower_http::cors::CorsLayer; + +pub fn cors() -> CorsLayer { + CorsLayer::new() + .allow_headers([ + header::CONTENT_TYPE, + header::AUTHORIZATION, + HeaderName::from_static("x-custom-header"), + ]) + // allow `GET` and `POST` when accessing the resource + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::OPTIONS, + ]) + // allow requests from any origin + .allow_origin([ + "http://localhost:1420".parse().unwrap(), + "http://localhost:5173".parse().unwrap(), + "http://tauri.localhost".parse().unwrap(), + ]) +} diff --git a/server/src/middlewares/decrypt.rs b/server/src/middlewares/decrypt.rs new file mode 100644 index 00000000..6a21121e --- /dev/null +++ b/server/src/middlewares/decrypt.rs @@ -0,0 +1,152 @@ +use axum::{ + body::{self, Body}, + extract::{Request, State}, + http::{HeaderMap, StatusCode, header}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; + +use crate::{ + svc_ctx::SvcCtx, + utils::{self, AesSecret, get_rsa_secret_instance}, +}; + +/// 被加密的请求的结构体 +#[derive(Debug, Deserialize)] +struct EncryptedRequest { + /// 是否加密 + encrypted: bool, + /// 加密的内容 + #[serde(default)] + data: String, + /// 使用服务器公钥加密后的AES密钥 + #[serde(default)] + key: String, +} + +/// 解密中间件 +pub async fn decrypt( + State(state): State, + req: Request, + next: Next, +) -> Result { + // 获取请求路径和请求方法 + { + // if state + // .config + // .client_gateway_service_config + // .resources + // .whitelists + // .contains(&combine) + // { + // return Ok(next.run(req).await); + // } + } + + // 检查内容是否是json + if !is_json_content_type(req.headers()) { + return Ok(next.run(req).await); + } + + // 读取请求体 + let (parts, body) = req.into_parts(); + let body_bytes = match body::to_bytes(body, 1024 * 1024 * 10).await { + // 限制请求体大小为10MB + Ok(bytes) => bytes, + Err(e) => { + tracing::error!("读取请求体失败: {}", e); + return Err(StatusCode::BAD_REQUEST); + } + }; + + match serde_json::from_slice::(&body_bytes) { + Ok(encrypted_request) => { + if !encrypted_request.encrypted { + // let req = Request::from_parts(parts, Body::from(body_bytes)); + // return Ok(next.run(req).await); + tracing::error!("请求未加密, 拒绝请求"); + return Err(StatusCode::BAD_REQUEST); + } + + // 获取服务器RSA实例以解密AES密钥 + let rsa_secret = get_rsa_secret_instance(); + + // 解密aes密钥(解密后是base64编码的字符串对应的字节数组) + // 解密失败后返回正常的json信息并设置状态码为400 + let aes_key = if let Ok(aes_key) = rsa_secret.decrypt(&encrypted_request.key) { + aes_key + } else { + let mut response = utils::Response::<()>::fail(Some("LASDE")).into_response(); + *response.status_mut() = StatusCode::UNPROCESSABLE_ENTITY; + return Ok(response); + }; + + let aes_key = std::str::from_utf8(&aes_key).map_err(|_| { + tracing::error!("AES密钥转换为字符串失败"); + StatusCode::BAD_REQUEST + })?; + + // 使用解密后的AES密钥创建AES实例 + let aes_secret = AesSecret::try_from(aes_key).map_err(|_| { + tracing::error!("创建AES实例失败"); + StatusCode::BAD_REQUEST + })?; + + // 使用aes密钥解密请求体 + let decrypted_data = aes_secret.decrypt(&encrypted_request.data).map_err(|e| { + tracing::error!("解密请求体失败: {:?}", e); + StatusCode::BAD_REQUEST + })?; + + // 将解密后的请求体转换为JSON + let mut decrypted_json_value: serde_json::Value = + serde_json::from_slice(&decrypted_data).map_err(|_| { + tracing::error!("解析解密数据为JSON失败"); + StatusCode::BAD_REQUEST + })?; + + // 获取api_secret + if let Some(api_secret) = decrypted_json_value.get("api_secret") { + // 检查api_secret是否与配置中的一致 + let app_secret = &state.config.app.secret; + if api_secret != &serde_json::Value::String(app_secret.clone()) { + tracing::error!("API密钥不匹配"); + return Err(StatusCode::UNAUTHORIZED); + } + + // 移除api_secret字段 + decrypted_json_value.as_object_mut().and_then(|obj| obj.remove("api_secret")); + } else { + tracing::error!("请求中缺少api_secret字段"); + return Err(StatusCode::BAD_REQUEST); + } + + // 重建请求体 + let json_bytes = serde_json::to_vec(&decrypted_json_value).map_err(|_| { + tracing::error!("序列化解密数据为JSON失败"); + StatusCode::BAD_REQUEST + })?; + + // 将解密后的请求体放入请求的扩展中 + let req = Request::from_parts(parts, Body::from(json_bytes)); + Ok(next.run(req).await) + } + Err(e) => { + // 如果解析失败,可能不是加密请求,直接通过 + // let req = Request::from_parts(parts, Body::from(body_bytes)); + // Ok(next.run(req).await) + tracing::error!("解析加密请求失败: {:?}", e); + return Err(StatusCode::BAD_REQUEST); + } + } +} + +/// 检查请求头是否为JSON内容类型 +fn is_json_content_type(headers: &HeaderMap) -> bool { + headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|content_type| content_type.starts_with("application/json")) + .unwrap_or(false) +} diff --git a/server/src/middlewares/encrypt.rs b/server/src/middlewares/encrypt.rs new file mode 100644 index 00000000..aec32862 --- /dev/null +++ b/server/src/middlewares/encrypt.rs @@ -0,0 +1,126 @@ +// use arcadia_codegen::user_services::GetUserPublicKeyRequest; +// use arcadia_utils::secret::{aes::AesSecret, rsa}; + +use axum::{ + body, + extract::{Request, State}, + http::{StatusCode, header}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use bytes::Bytes; +use serde_json::json; + +use crate::{ + services::get_user_public_key_service, + state::CurrentUser, + svc_ctx::SvcCtx, + utils::{AesSecret, get_rsa_secret_instance}, +}; + +/// 加密中间件 +pub async fn encrypt( + State(state): State, + req: Request, + next: Next, +) -> Result { + // 从请求中获取 CurrentUser(如果存在) + let current_user = req.extensions().get::().cloned(); + + // 获取请求路径和请求方法 + // { + // let (resource_method, resource_path) = get_request_path_and_method(&req); + // let combine = format!("{}+{}", resource_method, resource_path); + // if state.config.resources.whitelists.contains(&combine) { + // return Ok(next.run(req).await); + // } + // } + + let response = next.run(req).await; + + if let Some(current_user) = current_user { + match get_user_public_key_service(&state, ¤t_user.user_uuid).await { + Ok(public_key) => { + // 创建AES密钥实例 + let aes_secret = AesSecret::new(); + + // 读取响应体 + let (parts, body) = response.into_parts(); + + // 将整个响应体转换为字节 + let body_bytes = match body::to_bytes(body, usize::MAX).await { + Ok(bytes) => bytes, + Err(err) => { + // 无法读取响应体,返回错误 + tracing::error!("Failed to read response body: {}", err); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + // 解析原始JSON响应 + if let Ok(json_value) = serde_json::from_slice::(&body_bytes) { + // 加密JSON内容 + if let Ok(encrypted_content) = + aes_secret.encrypt(json_value.to_string().as_bytes()) + { + // 获取AES密钥的Base64表示 + let aes_key_base64 = aes_secret.get_key_as_base64(); + + // 使用用户的公钥加密AES密钥 + if let Ok(encrypted_key) = get_rsa_secret_instance() + .encrypt_with_public_key(aes_key_base64.as_bytes(), &public_key) + { + // 构建加密响应 + let secure_response = json!({ + "data": encrypted_content, + "encrypted": true, + "key": encrypted_key + }); + + // 创建新的响应 + let mut new_response = axum::Json(secure_response).into_response(); + + // 复制原始响应的状态码 + *new_response.status_mut() = parts.status; + + // 添加内容类型头 + new_response.headers_mut().insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("application/json"), + ); + + return Ok(new_response); + } + } + } + + // 如果加密失败,回退到原始响应 + let fallback = fallback_to_unencrypted_response(body_bytes, parts.status); + return Ok(fallback); + } + Err(_) => { + return Ok(response); + } + } + } + + Ok(response) +} + +// 当加密失败时回退到未加密响应 +fn fallback_to_unencrypted_response(body: Bytes, status: StatusCode) -> Response { + // 尝试解析原始响应为JSON + let json_value = serde_json::from_slice::(&body) + .unwrap_or_else(|_| json!({"raw": String::from_utf8_lossy(&body).to_string()})); + + // 添加未加密标志 + let unencrypted_response = json!({ + "encrypted": false, + "data": json_value + }); + + let mut response = axum::Json(unencrypted_response).into_response(); + *response.status_mut() = status; + + response +} diff --git a/server/src/middlewares/local_api_auth.rs b/server/src/middlewares/local_api_auth.rs new file mode 100644 index 00000000..53dcbaa8 --- /dev/null +++ b/server/src/middlewares/local_api_auth.rs @@ -0,0 +1,219 @@ +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::Response, +}; +use chrono::Utc; + +use crate::{ + caches::{ + LocalApiKeyCache, LocalApiPermissionCache, get_local_api_key_cache, + get_local_api_permission_cache, get_local_api_permission_definition_cache, + get_local_api_rate_count, increment_local_api_rate_count, set_local_api_key_cache, + set_local_api_permission_cache, set_local_api_permission_definition_cache, + }, + middlewares::{fill_authenticated_request_context, get_request_path_and_method}, + models, + svc_ctx::SvcCtx, +}; + +const LOCAL_API_AUTH_HEADER: &str = "x-local-api-auth"; +const LOCAL_API_KEY_HEADER: &str = "x-local-api-key"; +const LOCAL_API_PERMISSION_HEADER: &str = "x-local-api-permission"; + +pub fn has_local_api_auth_headers(req: &Request) -> bool { + req.headers().contains_key(LOCAL_API_AUTH_HEADER) + || (req.headers().contains_key(LOCAL_API_KEY_HEADER) + && req.headers().contains_key(LOCAL_API_PERMISSION_HEADER)) +} + +fn is_local_api_management_path(path: &str) -> bool { + let business_path = crate::middlewares::extract_resource_path(path); + business_path.starts_with("/local-api") +} + +fn parse_local_api_auth(req: &Request) -> Option<(String, String)> { + if let Some(combined) = req + .headers() + .get(LOCAL_API_AUTH_HEADER) + .and_then(|value| value.to_str().ok()) + { + let (api_key, permission_code) = combined.split_once(':')?; + let api_key = api_key.trim(); + let permission_code = permission_code.trim(); + + if !api_key.is_empty() && !permission_code.is_empty() { + return Some((api_key.to_string(), permission_code.to_string())); + } + } + + let api_key = req + .headers() + .get(LOCAL_API_KEY_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let permission_code = req + .headers() + .get(LOCAL_API_PERMISSION_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty())?; + + Some((api_key.to_string(), permission_code.to_string())) +} + +pub async fn local_api_auth( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let (resource_method, resource_path) = get_request_path_and_method(&req); + let resource_method = resource_method.to_string(); + let resource_path = resource_path.to_string(); + + if resource_method == "OPTIONS" || is_local_api_management_path(&resource_path) { + return Ok(next.run(req).await); + } + + let Some((api_key, permission_code)) = parse_local_api_auth(&req) else { + return Ok(next.run(req).await); + }; + + let user_uuid = authorize_local_api_request(&state, api_key.as_str(), permission_code.as_str()) + .await?; + + fill_authenticated_request_context( + &state, + &mut req, + user_uuid, + &resource_method, + &resource_path, + ) + .await; + + Ok(next.run(req).await) +} + +async fn authorize_local_api_request( + state: &SvcCtx, + api_key: &str, + permission_code: &str, +) -> Result { + let key_hash = models::local_api::hash_api_key(api_key); + let api_key = if let Some(cache) = get_local_api_key_cache(state, &key_hash) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + cache + } else { + let db_api_key = models::local_api::fetch_api_key_by_hash(&state.db, &key_hash) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::UNAUTHORIZED)?; + + let cache = LocalApiKeyCache { + id: db_api_key.id, + user_uuid: db_api_key.user_uuid, + is_active: db_api_key.is_active, + expires_at: db_api_key.expires_at, + daily_limit: db_api_key.daily_limit, + }; + set_local_api_key_cache(state, &key_hash, &cache) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + cache + }; + + if !api_key.is_active { + return Err(StatusCode::UNAUTHORIZED); + } + + if let Some(expires_at) = api_key.expires_at { + if expires_at < Utc::now() { + return Err(StatusCode::UNAUTHORIZED); + } + } + + if get_local_api_permission_definition_cache(state, permission_code) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .is_none() + { + let definition = models::local_api::fetch_permission_definition(&state.db, permission_code) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::FORBIDDEN)?; + set_local_api_permission_definition_cache(state, permission_code, &definition) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + + let permission = if let Some(cache) = + get_local_api_permission_cache(state, api_key.id, permission_code) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + cache + } else { + let db_permission = + models::local_api::fetch_api_key_permission(&state.db, api_key.id, permission_code) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::FORBIDDEN)?; + let cache = LocalApiPermissionCache { + is_enabled: db_permission.is_enabled, + rate_limit_per_minute: db_permission.rate_limit_per_minute, + rate_limit_per_hour: db_permission.rate_limit_per_hour, + }; + set_local_api_permission_cache(state, api_key.id, permission_code, &cache) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + cache + }; + + if !permission.is_enabled { + return Err(StatusCode::FORBIDDEN); + } + + let now = Utc::now(); + let day_key = now.format("%Y%m%d").to_string(); + let hour_key = now.format("%Y%m%d%H").to_string(); + let minute_key = now.format("%Y%m%d%H%M").to_string(); + + let day_count = get_local_api_rate_count(state, api_key.id, None, "day", &day_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if day_count >= i64::from(api_key.daily_limit) { + return Err(StatusCode::TOO_MANY_REQUESTS); + } + + let minute_count = + get_local_api_rate_count(state, api_key.id, Some(permission_code), "minute", &minute_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if minute_count >= i64::from(permission.rate_limit_per_minute) { + return Err(StatusCode::TOO_MANY_REQUESTS); + } + + let hour_count = + get_local_api_rate_count(state, api_key.id, Some(permission_code), "hour", &hour_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if hour_count >= i64::from(permission.rate_limit_per_hour) { + return Err(StatusCode::TOO_MANY_REQUESTS); + } + + increment_local_api_rate_count(state, api_key.id, Some(permission_code), "minute", &minute_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + increment_local_api_rate_count(state, api_key.id, Some(permission_code), "hour", &hour_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + increment_local_api_rate_count(state, api_key.id, None, "day", &day_key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(api_key.user_uuid) +} diff --git a/server/src/middlewares/logger.rs b/server/src/middlewares/logger.rs new file mode 100644 index 00000000..045f09c8 --- /dev/null +++ b/server/src/middlewares/logger.rs @@ -0,0 +1,67 @@ +use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response}; +use once_cell::sync::Lazy; +use tokio::{sync::mpsc, time::Instant}; + +#[derive(Debug)] +struct LogEntry { + method: String, + path: String, + status: u16, + latency: u64, +} + +/// 日志通道, 该通道用于发送日志信息 +static LOG_CHANNEL: Lazy> = Lazy::new(|| { + let (tx, rx) = mpsc::channel::(100_000); + spawn_log_processor(rx); + tx +}); + +fn spawn_log_processor(mut rx: mpsc::Receiver) { + // 使用线程而非tokio spawn + // tokio线程用于处理短暂的非阻塞的任务, 如果使用tokio spawn, 会导致整个异步运行时的性能 + std::thread::Builder::new() + .name("log-processor".to_string()) + .spawn(move || { + loop { + match rx.blocking_recv() { + Some(LogEntry { + method, + path, + status, + latency, + }) => { + tracing::info!( + "Request completed: {} {} status={} latency={:?}ms", + method, + path, + status, + latency + ); + } + _ => continue, + } + } + }) + .expect("Failed to spawn log processor thread"); +} + +pub async fn logger(req: Request, next: Next) -> Result { + let start = Instant::now(); + let method = req.method().to_string(); + let path = req.uri().path().to_string(); + + // 处理请求 + let response = next.run(req).await; + + let status = response.status().as_u16(); + + let latency = start.elapsed().as_millis() as u64; + let _ = LOG_CHANNEL.try_send(LogEntry { + method, + path, + status, + latency, + }); + Ok(response) +} diff --git a/server/src/middlewares/real_ip.rs b/server/src/middlewares/real_ip.rs new file mode 100644 index 00000000..5cae48ae --- /dev/null +++ b/server/src/middlewares/real_ip.rs @@ -0,0 +1,47 @@ +use std::net::SocketAddr; + +use axum::{ + extract::{ConnectInfo, Request}, + http::StatusCode, + middleware::Next, + response::Response, +}; + +use crate::state::{CurrentIpAddr, RequestContext}; + +pub async fn real_ip(mut req: Request, next: Next) -> Result { + // 验证来源 + let client_ip_opt = req + .extensions() + .get::>() + .map(|ConnectInfo(addr)| addr.ip()); + + // 检查ip地址是否是通过nginx转发(通过nginx就是127.0.0.1)和是否是本地地址 + if let Some(client_ip) = client_ip_opt { + let client_ip_str = client_ip.to_string(); + if &client_ip_str != "127.0.0.1" && !client_ip.is_loopback() { + return Err(StatusCode::FORBIDDEN); + } + } + + // 提取客户端的真实IP + let real_ip = req + .headers() + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.split(',').next()) + .map(str::trim) + .map(str::to_string) + .or_else(|| { + req.headers().get("x-real-ip").and_then(|v| v.to_str().ok()).map(str::to_string) + }); + + // 初始化 RequestContext 并设置 IP + let mut ctx = RequestContext::default(); + if let Some(ip) = real_ip { + ctx.current_ip_addr = Some(CurrentIpAddr { real_ip: ip }); + } + req.extensions_mut().insert(ctx); + + Ok(next.run(req).await) +} diff --git a/server/src/models/accounts.rs b/server/src/models/accounts.rs new file mode 100644 index 00000000..f75a8cda --- /dev/null +++ b/server/src/models/accounts.rs @@ -0,0 +1,323 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::PlatformAccountDto; + +/// 创建平台账号 +pub async fn insert_platform_account( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + platform_url: &str, + platform_name: Option<&str>, + account: &str, + password: Option<&str>, + remark: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO platform_accounts (user_uuid, team_uuid, platform_url, platform_name, + account, password, remark) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(platform_url) + .bind(platform_name) + .bind(account) + .bind(password) + .bind(remark) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询平台账号列表 +pub async fn fetch_platform_accounts( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + keyword: Option<&str>, + platform_name: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let keyword = keyword.map(|value| format!("%{}%", value.trim())); + + let recs = sqlx::query_as::<_, PlatformAccountDto>( + r#" + SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, + pa.account, pa.password, pa.status, pa.remark, pa.usage_count, + (SELECT COUNT(*) FROM environment_accounts ea WHERE ea.account_uuid = pa.uuid) AS environments_count, + pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at + FROM platform_accounts pa + WHERE (pa.team_uuid = $1 OR (pa.team_uuid IS NULL AND pa.user_uuid = $2)) + AND ( + $3::text IS NULL + OR pa.platform_url ILIKE $3 + OR COALESCE(pa.platform_name, '') ILIKE $3 + OR pa.account ILIKE $3 + OR COALESCE(pa.remark, '') ILIKE $3 + ) + AND ($4::varchar IS NULL OR pa.platform_name = $4) + AND ($5::varchar IS NULL OR pa.status = $5) + AND pa.deleted_at IS NULL + ORDER BY pa.created_at DESC + LIMIT $6 OFFSET $7 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(keyword) + .bind(platform_name) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询平台账号总数 +pub async fn fetch_platform_accounts_count( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + keyword: Option<&str>, + platform_name: Option<&str>, + status: Option<&str>, +) -> Result { + let keyword = keyword.map(|value| format!("%{}%", value.trim())); + + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM platform_accounts + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND ( + $3::text IS NULL + OR platform_url ILIKE $3 + OR COALESCE(platform_name, '') ILIKE $3 + OR account ILIKE $3 + OR COALESCE(remark, '') ILIKE $3 + ) + AND ($4::varchar IS NULL OR platform_name = $4) + AND ($5::varchar IS NULL OR status = $5) + AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(keyword) + .bind(platform_name) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询平台账号 +pub async fn fetch_platform_account_by_uuid( + pool: &Pool, + account_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, PlatformAccountDto>( + r#" + SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, + pa.account, pa.password, pa.status, pa.remark, pa.usage_count, + (SELECT COUNT(*) FROM environment_accounts ea WHERE ea.account_uuid = pa.uuid) AS environments_count, + pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at + FROM platform_accounts pa + WHERE pa.uuid = $1 AND pa.deleted_at IS NULL + "#, + ) + .bind(account_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新平台账号 +pub async fn update_platform_account( + pool: &Pool, + account_uuid: Uuid, + platform_url: Option<&str>, + platform_name: Option<&str>, + account: Option<&str>, + password: Option<&str>, + remark: Option<&str>, + status: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE platform_accounts + SET platform_url = COALESCE($1, platform_url), + platform_name = COALESCE($2, platform_name), + account = COALESCE($3, account), + password = COALESCE($4, password), + remark = COALESCE($5, remark), + status = COALESCE($6, status) + WHERE uuid = $7 AND deleted_at IS NULL + "#, + ) + .bind(platform_url) + .bind(platform_name) + .bind(account) + .bind(password) + .bind(remark) + .bind(status) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加账号使用次数 +pub async fn increment_account_usage( + pool: &Pool, + account_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE platform_accounts + SET usage_count = usage_count + 1, last_used_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除平台账号 +pub async fn delete_platform_account( + pool: &Pool, + account_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE platform_accounts SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除平台账号 +pub async fn batch_delete_platform_accounts( + pool: &Pool, + account_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE platform_accounts SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(account_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Environment Accounts ============ + +/// 关联环境和账号 +pub async fn insert_environment_account( + pool: &Pool, + env_uuid: Uuid, + account_uuid: Uuid, + sort_order: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO environment_accounts (environment_uuid, account_uuid, sort_order) + VALUES ($1, $2, $3) + ON CONFLICT (environment_uuid, account_uuid) DO UPDATE SET sort_order = $3; + "#, + ) + .bind(env_uuid) + .bind(account_uuid) + .bind(sort_order) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除环境和账号的关联 +pub async fn remove_environment_account( + pool: &Pool, + env_uuid: Uuid, + account_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_accounts + WHERE environment_uuid = $1 AND account_uuid = $2; + "#, + ) + .bind(env_uuid) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清空环境的所有账号关联 +pub async fn clear_environment_accounts( + pool: &Pool, + env_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_accounts WHERE environment_uuid = $1; + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询环境关联的所有账号 +pub async fn fetch_environment_accounts( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, PlatformAccountDto>( + r#" + SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, + pa.account, pa.password, pa.status, pa.remark, pa.usage_count, + (SELECT COUNT(*) FROM environment_accounts ea2 WHERE ea2.account_uuid = pa.uuid) AS environments_count, + pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at + FROM platform_accounts pa + INNER JOIN environment_accounts ea ON pa.uuid = ea.account_uuid + WHERE ea.environment_uuid = $1 AND pa.deleted_at IS NULL + ORDER BY ea.sort_order + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/server/src/models/audit.rs b/server/src/models/audit.rs new file mode 100644 index 00000000..e88280e1 --- /dev/null +++ b/server/src/models/audit.rs @@ -0,0 +1,242 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::AuditLogDto; + +/// 记录审计日志 +pub async fn insert_audit_log( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + action: &str, + target_type: &str, + target_uuid: Option, + target_name: Option<&str>, + details: Option<&str>, + changes: Option<&serde_json::Value>, + ip_address: Option<&str>, + user_agent: Option<&str>, + request_id: Option<&str>, +) -> Result { + let id: i64 = sqlx::query_scalar( + r#" + INSERT INTO audit_logs (user_uuid, team_uuid, action, target_type, target_uuid, + target_name, details, changes, ip_address, user_agent, request_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(action) + .bind(target_type) + .bind(target_uuid) + .bind(target_name) + .bind(details) + .bind(changes) + .bind(ip_address) + .bind(user_agent) + .bind(request_id) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询审计日志列表 +/// +/// 查询逻辑: +/// - 当前团队的所有审计日志 +/// - 加上当前用户的个人操作日志(team_uuid 为空的,如登录、注册等) +pub async fn fetch_audit_logs( + pool: &Pool, + current_user_uuid: Uuid, + team_uuid: Option, + user_uuid_filter: Option, + keyword: Option<&str>, + action: Option<&str>, + target_type: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, AuditLogDto>( + r#" + SELECT + a.id, a.uuid, a.user_uuid, a.team_uuid, a.action, a.target_type, a.target_uuid, + a.target_name, a.details, a.changes, a.ip_address, a.user_agent, a.request_id, a.created_at, + ui.nickname AS user_name, ui.email AS user_email + FROM audit_logs a + LEFT JOIN user_infos ui ON a.user_uuid = ui.user_uuid + WHERE ( + ($2::uuid IS NULL OR a.team_uuid = $2) + OR (a.team_uuid IS NULL AND a.user_uuid = $1) + ) + AND ($3::uuid IS NULL OR a.user_uuid = $3) + AND ($4::text IS NULL OR COALESCE(a.details, '') ILIKE '%' || $4 || '%') + AND ($5::varchar IS NULL OR a.action = $5) + AND ($6::varchar IS NULL OR a.target_type = $6) + ORDER BY a.created_at DESC + LIMIT $7 OFFSET $8 + "#, + ) + .bind(current_user_uuid) + .bind(team_uuid) + .bind(user_uuid_filter) + .bind(keyword) + .bind(action) + .bind(target_type) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询审计日志总数 +pub async fn fetch_audit_logs_count( + pool: &Pool, + current_user_uuid: Uuid, + team_uuid: Option, + user_uuid_filter: Option, + keyword: Option<&str>, + action: Option<&str>, + target_type: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM audit_logs + WHERE ( + ($2::uuid IS NULL OR team_uuid = $2) + OR (team_uuid IS NULL AND user_uuid = $1) + ) + AND ($3::uuid IS NULL OR user_uuid = $3) + AND ($4::text IS NULL OR COALESCE(details, '') ILIKE '%' || $4 || '%') + AND ($5::varchar IS NULL OR action = $5) + AND ($6::varchar IS NULL OR target_type = $6) + "#, + ) + .bind(current_user_uuid) + .bind(team_uuid) + .bind(user_uuid_filter) + .bind(keyword) + .bind(action) + .bind(target_type) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询审计日志 +pub async fn fetch_audit_log_by_uuid( + pool: &Pool, + log_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, AuditLogDto>( + r#" + SELECT + a.id, a.uuid, a.user_uuid, a.team_uuid, a.action, a.target_type, a.target_uuid, + a.target_name, a.details, a.changes, a.ip_address, a.user_agent, a.request_id, a.created_at, + ui.nickname AS user_name, ui.email AS user_email + FROM audit_logs a + LEFT JOIN user_infos ui ON a.user_uuid = ui.user_uuid + WHERE a.uuid = $1 + "#, + ) + .bind(log_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询指定日期的审计日志数量 +pub async fn fetch_audit_logs_count_by_date( + pool: &Pool, + team_uuid: Option, + date: chrono::NaiveDate, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM audit_logs + WHERE ($1::uuid IS NULL OR team_uuid = $1) + AND created_at::date = $2 + "#, + ) + .bind(team_uuid) + .bind(date) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 查询指定日期之后的审计日志数量 +pub async fn fetch_audit_logs_count_since_date( + pool: &Pool, + team_uuid: Option, + since_date: chrono::NaiveDate, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM audit_logs + WHERE ($1::uuid IS NULL OR team_uuid = $1) + AND created_at::date >= $2 + "#, + ) + .bind(team_uuid) + .bind(since_date) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 查询热门操作类型 +pub async fn fetch_top_actions( + pool: &Pool, + team_uuid: Option, + limit: i64, +) -> Result, Error> { + let rows: Vec<(String, i64)> = sqlx::query_as( + r#" + SELECT action, COUNT(*) as count FROM audit_logs + WHERE ($1::uuid IS NULL OR team_uuid = $1) + GROUP BY action + ORDER BY count DESC + LIMIT $2 + "#, + ) + .bind(team_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(rows) +} + +/// 查询热门目标类型 +pub async fn fetch_top_target_types( + pool: &Pool, + team_uuid: Option, + limit: i64, +) -> Result, Error> { + let rows: Vec<(String, i64)> = sqlx::query_as( + r#" + SELECT target_type, COUNT(*) as count FROM audit_logs + WHERE ($1::uuid IS NULL OR team_uuid = $1) + GROUP BY target_type + ORDER BY count DESC + LIMIT $2 + "#, + ) + .bind(team_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(rows) +} diff --git a/server/src/models/billing.rs b/server/src/models/billing.rs new file mode 100644 index 00000000..3b271a7f --- /dev/null +++ b/server/src/models/billing.rs @@ -0,0 +1,1073 @@ +use chrono::NaiveDate; +use rust_decimal::Decimal; +use sqlx::{Error, Row}; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{ + AutoRenewalServiceDto, CouponDto, InvoiceDto, PaymentOrderDto, PlanDto, PlanFeatureDto, + SubscriptionDto, UserQuotaDto, UserWalletDto, WalletTransactionDto, +}; + +// ============ Plans ============ + +/// 查询所有可用套餐 +pub async fn fetch_plans(pool: &Pool) -> Result, Error> { + let recs = sqlx::query_as::<_, PlanDto>( + r#" + SELECT id, uuid, name, description, price_per_month, price_per_year, + currency, discount_monthly, discount_yearly, max_environments, + max_team_members, max_proxies, max_rpa_tasks, is_recommended, + sort_order, status, created_at, updated_at + FROM plans + WHERE status = 'active' + ORDER BY sort_order ASC, price_per_month ASC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据 UUID 查询套餐 +pub async fn fetch_plan_by_uuid( + pool: &Pool, + plan_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, PlanDto>( + r#" + SELECT id, uuid, name, description, price_per_month, price_per_year, + currency, discount_monthly, discount_yearly, max_environments, + max_team_members, max_proxies, max_rpa_tasks, is_recommended, + sort_order, status, created_at, updated_at + FROM plans + WHERE uuid = $1 + "#, + ) + .bind(plan_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询套餐特性 +pub async fn fetch_plan_features( + pool: &Pool, + plan_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, PlanFeatureDto>( + r#" + SELECT id, plan_uuid, feature_key, feature_name, feature_value, + is_included, sort_order, created_at + FROM plan_features + WHERE plan_uuid = $1 + ORDER BY sort_order ASC + "#, + ) + .bind(plan_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +// ============ Subscriptions ============ + +/// 查询用户当前订阅(按用户) +pub async fn fetch_active_subscription( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, SubscriptionDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, plan_uuid, billing_period, price, currency, + started_at, expires_at, next_billing_date, auto_renew, status, + cancelled_at, created_at, updated_at + FROM subscriptions + WHERE user_uuid = $1 AND status IN ('active', 'paused') + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询工作空间当前订阅 +pub async fn fetch_workspace_active_subscription( + pool: &Pool, + workspace_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, SubscriptionDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, plan_uuid, billing_period, price, currency, + started_at, expires_at, next_billing_date, auto_renew, status, + cancelled_at, created_at, updated_at + FROM subscriptions + WHERE workspace_uuid = $1 AND status IN ('active', 'paused') + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 根据 UUID 查询订阅 +pub async fn fetch_subscription_by_uuid( + pool: &Pool, + subscription_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, SubscriptionDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, plan_uuid, billing_period, price, currency, + started_at, expires_at, next_billing_date, auto_renew, status, + cancelled_at, created_at, updated_at + FROM subscriptions + WHERE uuid = $1 + "#, + ) + .bind(subscription_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 创建订阅 +pub async fn insert_subscription( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + plan_uuid: Uuid, + billing_period: &str, + price: Decimal, + currency: &str, + expires_at: chrono::DateTime, + next_billing_date: NaiveDate, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO subscriptions (workspace_uuid, user_uuid, plan_uuid, billing_period, price, currency, + started_at, expires_at, next_billing_date, auto_renew, status) + VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP, $7, $8, true, 'active') + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(plan_uuid) + .bind(billing_period) + .bind(price) + .bind(currency) + .bind(expires_at) + .bind(next_billing_date) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 取消订阅 +pub async fn cancel_subscription( + pool: &Pool, + subscription_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE subscriptions + SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $1 + "#, + ) + .bind(subscription_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 恢复订阅 +pub async fn resume_subscription( + pool: &Pool, + subscription_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE subscriptions + SET status = 'active', cancelled_at = NULL, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $1 + "#, + ) + .bind(subscription_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 切换自动续费 +pub async fn toggle_auto_renew( + pool: &Pool, + subscription_uuid: Uuid, + auto_renew: bool, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE subscriptions + SET auto_renew = $1, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $2 + "#, + ) + .bind(auto_renew) + .bind(subscription_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ User Wallet ============ + +/// 获取用户钱包 +pub async fn fetch_user_wallet( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserWalletDto>( + r#" + SELECT id, user_uuid, balance, currency, frozen_amount, auto_renewal_combined, + created_at, updated_at + FROM user_wallets + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 创建用户钱包 +pub async fn insert_user_wallet( + pool: &Pool, + user_uuid: Uuid, + currency: &str, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO user_wallets (user_uuid, balance, currency, frozen_amount, auto_renewal_combined) + VALUES ($1, 0, $2, 0, 0) + ON CONFLICT (user_uuid) DO NOTHING + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(currency) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 更新钱包余额 +pub async fn update_wallet_balance( + pool: &Pool, + user_uuid: Uuid, + amount: Decimal, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_wallets + SET balance = balance + $1, updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $2 + "#, + ) + .bind(amount) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Wallet Transactions ============ + +/// 查询钱包交易记录 +pub async fn fetch_wallet_transactions( + pool: &Pool, + user_uuid: Uuid, + transaction_type: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, WalletTransactionDto>( + r#" + SELECT id, uuid, user_uuid, transaction_type, amount, currency, + balance_before, balance_after, description, order_uuid, status, created_at + FROM wallet_transactions + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR transaction_type = $2) + ORDER BY created_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(user_uuid) + .bind(transaction_type) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询交易记录总数 +pub async fn fetch_wallet_transactions_count( + pool: &Pool, + user_uuid: Uuid, + transaction_type: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM wallet_transactions + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR transaction_type = $2) + "#, + ) + .bind(user_uuid) + .bind(transaction_type) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 插入钱包交易记录 +pub async fn insert_wallet_transaction( + pool: &Pool, + user_uuid: Uuid, + transaction_type: &str, + amount: Decimal, + currency: &str, + balance_before: Decimal, + balance_after: Decimal, + description: Option<&str>, + order_uuid: Option, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO wallet_transactions (user_uuid, transaction_type, amount, currency, + balance_before, balance_after, description, order_uuid, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'completed') + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(transaction_type) + .bind(amount) + .bind(currency) + .bind(balance_before) + .bind(balance_after) + .bind(description) + .bind(order_uuid) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +// ============ Invoices ============ + +/// 查询发票列表 +pub async fn fetch_invoices( + pool: &Pool, + user_uuid: Uuid, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, InvoiceDto>( + r#" + SELECT id, uuid, user_uuid, invoice_number, amount, currency, + subscription_uuid, order_uuid, invoice_type, status, + issued_at, due_at, paid_at, invoice_url, created_at, updated_at + FROM invoices + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR status = $2) + ORDER BY created_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(user_uuid) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询发票总数 +pub async fn fetch_invoices_count( + pool: &Pool, + user_uuid: Uuid, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM invoices + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR status = $2) + "#, + ) + .bind(user_uuid) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 创建发票 +pub async fn insert_invoice( + pool: &Pool, + user_uuid: Uuid, + invoice_number: &str, + amount: Decimal, + currency: &str, + subscription_uuid: Option, + order_uuid: Option, + invoice_type: &str, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO invoices (user_uuid, invoice_number, amount, currency, + subscription_uuid, order_uuid, invoice_type, status, issued_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', CURRENT_TIMESTAMP) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(invoice_number) + .bind(amount) + .bind(currency) + .bind(subscription_uuid) + .bind(order_uuid) + .bind(invoice_type) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +// ============ User Quotas ============ + +/// 获取用户配额 +pub async fn fetch_user_quota( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserQuotaDto>( + r#" + SELECT id, user_uuid, max_environments, used_environments, max_team_members, + max_proxies, used_proxies, max_rpa_tasks, used_rpa_tasks, + created_at, updated_at + FROM user_quotas + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 创建或更新用户配额 +pub async fn upsert_user_quota( + pool: &Pool, + user_uuid: Uuid, + max_environments: i32, + max_team_members: i32, + max_proxies: i32, + max_rpa_tasks: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_quotas (user_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_uuid) DO UPDATE SET + max_environments = $2, + max_team_members = $3, + max_proxies = $4, + max_rpa_tasks = $5, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(max_environments) + .bind(max_team_members) + .bind(max_proxies) + .bind(max_rpa_tasks) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Coupons ============ + +/// 根据优惠码查询优惠券 +pub async fn fetch_coupon_by_code( + pool: &Pool, + code: &str, +) -> Result, Error> { + let rec = sqlx::query_as::<_, CouponDto>( + r#" + SELECT id, uuid, code, name, description, discount_type, discount_value, min_amount, max_discount, + max_uses, used_count, max_uses_per_user, valid_from, valid_until, + applicable_to, status, created_at, updated_at + FROM coupons + WHERE code = $1 AND status = 'active' + "#, + ) + .bind(code) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 根据 UUID 查询优惠券 +pub async fn fetch_coupon_by_uuid( + pool: &Pool, + coupon_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, CouponDto>( + r#" + SELECT id, uuid, code, name, description, discount_type, discount_value, min_amount, max_discount, + max_uses, used_count, max_uses_per_user, valid_from, valid_until, + applicable_to, status, created_at, updated_at + FROM coupons + WHERE uuid = $1 AND status = 'active' + "#, + ) + .bind(coupon_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询用户优惠券使用次数 +pub async fn fetch_coupon_user_usage_count( + pool: &Pool, + coupon_uuid: Uuid, + user_uuid: Uuid, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM coupon_usages + WHERE coupon_uuid = $1 AND user_uuid = $2 + "#, + ) + .bind(coupon_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + Ok(count as i32) +} + +/// 记录优惠券使用 +pub async fn insert_coupon_usage( + pool: &Pool, + coupon_uuid: Uuid, + user_uuid: Uuid, + order_uuid: Option, + discount_amount: Decimal, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO coupon_usages (coupon_uuid, user_uuid, order_uuid, discount_amount) + VALUES ($1, $2, $3, $4) + RETURNING id; + "#, + ) + .bind(coupon_uuid) + .bind(user_uuid) + .bind(order_uuid) + .bind(discount_amount) + .fetch_one(pool) + .await?; + + // 更新优惠券使用次数 + sqlx::query("UPDATE coupons SET used_count = used_count + 1 WHERE uuid = $1") + .bind(coupon_uuid) + .execute(pool) + .await?; + + Ok(id) +} + +// ============ User Coupons ============ + +/// 查询用户优惠券列表 +pub async fn fetch_user_coupons( + pool: &Pool, + user_uuid: Uuid, + status: Option<&str>, + page: i64, + page_size: i64, +) -> Result<(Vec, i64), Error> { + let offset = (page - 1) * page_size; + + let (items, total) = if let Some(s) = status { + let rows = sqlx::query( + r#" + SELECT + uc.id, uc.user_uuid, uc.coupon_uuid, uc.status, + uc.issued_at, uc.used_at, uc.expires_at, + c.code, c.name, c.description, c.discount_type, c.discount_value, c.min_amount, c.max_discount + FROM user_coupons uc + INNER JOIN coupons c ON uc.coupon_uuid = c.uuid + WHERE uc.user_uuid = $1 AND uc.status = $2 + ORDER BY uc.issued_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(user_uuid) + .bind(s) + .bind(page_size) + .bind(offset) + .fetch_all(pool) + .await?; + + let items: Vec = rows + .into_iter() + .map(|row| crate::dto::UserCouponWithDetailsDto { + id: row.get(0), + user_uuid: row.get(1), + coupon_uuid: row.get(2), + status: row.get(3), + issued_at: row.get(4), + used_at: row.get(5), + expires_at: row.get(6), + code: row.get(7), + name: row.get(8), + description: row.get(9), + discount_type: row.get(10), + discount_value: row.get(11), + min_amount: row.get(12), + max_discount: row.get(13), + }) + .collect(); + + let total: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM user_coupons + WHERE user_uuid = $1 AND status = $2 + "#, + ) + .bind(user_uuid) + .bind(s) + .fetch_one(pool) + .await?; + + (items, total) + } else { + let rows = sqlx::query( + r#" + SELECT + uc.id, uc.user_uuid, uc.coupon_uuid, uc.status, + uc.issued_at, uc.used_at, uc.expires_at, + c.code, c.name, c.description, c.discount_type, c.discount_value, c.min_amount, c.max_discount + FROM user_coupons uc + INNER JOIN coupons c ON uc.coupon_uuid = c.uuid + WHERE uc.user_uuid = $1 + ORDER BY uc.issued_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(user_uuid) + .bind(page_size) + .bind(offset) + .fetch_all(pool) + .await?; + + let items: Vec = rows + .into_iter() + .map(|row| crate::dto::UserCouponWithDetailsDto { + id: row.get(0), + user_uuid: row.get(1), + coupon_uuid: row.get(2), + status: row.get(3), + issued_at: row.get(4), + used_at: row.get(5), + expires_at: row.get(6), + code: row.get(7), + name: row.get(8), + description: row.get(9), + discount_type: row.get(10), + discount_value: row.get(11), + min_amount: row.get(12), + max_discount: row.get(13), + }) + .collect(); + + let total: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM user_coupons + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + (items, total) + }; + + Ok((items, total)) +} + +/// 根据 UUID 查询用户优惠券 +pub async fn fetch_user_coupon_by_uuid( + pool: &Pool, + user_coupon_uuid: i32, +) -> Result, Error> { + let rec = sqlx::query_as::<_, crate::dto::UserCouponDto>( + r#" + SELECT id, user_uuid, coupon_uuid, status, issued_at, used_at, expires_at + FROM user_coupons + WHERE id = $1 + "#, + ) + .bind(user_coupon_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 发放优惠券给用户 +pub async fn insert_user_coupon( + pool: &Pool, + user_uuid: Uuid, + coupon_uuid: Uuid, + expires_at: Option>, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO user_coupons (user_uuid, coupon_uuid, expires_at) + VALUES ($1, $2, $3) + ON CONFLICT (user_uuid, coupon_uuid) DO NOTHING + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(coupon_uuid) + .bind(expires_at) + .fetch_optional(pool) + .await? + .ok_or_else(|| { + Error::RowNotFound // 如果已存在,返回错误 + })?; + + Ok(id) +} + +/// 更新用户优惠券状态 +pub async fn update_user_coupon_status( + pool: &Pool, + user_uuid: Uuid, + coupon_uuid: Uuid, + status: &str, + used_at: Option>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_coupons + SET status = $1, used_at = $2, updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $3 AND coupon_uuid = $4 + "#, + ) + .bind(status) + .bind(used_at) + .bind(user_uuid) + .bind(coupon_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量发放优惠券 +pub async fn batch_insert_user_coupons( + pool: &Pool, + coupon_uuid: Uuid, + user_uuids: &[Uuid], + expires_at: Option>, +) -> Result { + let mut tx = pool.begin().await?; + let mut count = 0; + + for user_uuid in user_uuids { + let result = sqlx::query_scalar::<_, i32>( + r#" + INSERT INTO user_coupons (user_uuid, coupon_uuid, expires_at) + VALUES ($1, $2, $3) + ON CONFLICT (user_uuid, coupon_uuid) DO NOTHING + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(coupon_uuid) + .bind(expires_at) + .fetch_optional(&mut *tx) + .await?; + + if result.is_some() { + count += 1; + } + } + + tx.commit().await?; + Ok(count) +} + +/// 查询用户可用优惠券(未使用且未过期,包含优惠券详细信息) +pub async fn fetch_available_user_coupons( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let now = chrono::Utc::now(); + + let rows = sqlx::query( + r#" + SELECT + uc.id, uc.user_uuid, uc.coupon_uuid, uc.status, + uc.issued_at, uc.used_at, uc.expires_at, + c.code, c.name, c.description, c.discount_type, c.discount_value, c.min_amount, c.max_discount + FROM user_coupons uc + INNER JOIN coupons c ON uc.coupon_uuid = c.uuid + WHERE uc.user_uuid = $1 + AND uc.status = 'unused' + AND c.status = 'active' + AND (uc.expires_at IS NULL OR uc.expires_at > $2) + AND (c.valid_until IS NULL OR c.valid_until > $2) + AND c.valid_from <= $2 + ORDER BY uc.issued_at DESC + "#, + ) + .bind(user_uuid) + .bind(now) + .fetch_all(pool) + .await?; + + let items = rows + .into_iter() + .map(|row| crate::dto::UserCouponWithDetailsDto { + id: row.get(0), + user_uuid: row.get(1), + coupon_uuid: row.get(2), + status: row.get(3), + issued_at: row.get(4), + used_at: row.get(5), + expires_at: row.get(6), + code: row.get(7), + name: row.get(8), + description: row.get(9), + discount_type: row.get(10), + discount_value: row.get(11), + min_amount: row.get(12), + max_discount: row.get(13), + }) + .collect(); + + Ok(items) +} + +// ============ Payment Orders ============ + +/// 创建支付订单 +pub async fn insert_payment_order( + pool: &Pool, + order_no: &str, + user_uuid: Uuid, + order_type: &str, + amount: Decimal, + currency: &str, + payment_channel: Option<&str>, + description: Option<&str>, + subscription_uuid: Option, + coupon_uuid: Option, + original_amount: Option, + discount_amount: Option, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO payment_orders (order_no, user_uuid, order_type, amount, currency, + status, payment_channel, description, subscription_uuid, + coupon_uuid, original_amount, discount_amount) + VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7, $8, $9, $10, $11) + RETURNING uuid; + "#, + ) + .bind(order_no) + .bind(user_uuid) + .bind(order_type) + .bind(amount) + .bind(currency) + .bind(payment_channel) + .bind(description) + .bind(subscription_uuid) + .bind(coupon_uuid) + .bind(original_amount) + .bind(discount_amount) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询支付订单列表 +pub async fn fetch_payment_orders( + pool: &Pool, + user_uuid: Uuid, + order_type: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, PaymentOrderDto>( + r#" + SELECT id, uuid, order_no, user_uuid, order_type, amount, currency, status, + payment_channel, external_order_id, description, subscription_uuid, + coupon_uuid, original_amount, discount_amount, paid_at, refunded_at, + created_at, updated_at + FROM payment_orders + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR order_type = $2) + AND ($3::varchar IS NULL OR status = $3) + ORDER BY created_at DESC + LIMIT $4 OFFSET $5 + "#, + ) + .bind(user_uuid) + .bind(order_type) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询支付订单总数 +pub async fn fetch_payment_orders_count( + pool: &Pool, + user_uuid: Uuid, + order_type: Option<&str>, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM payment_orders + WHERE user_uuid = $1 + AND ($2::varchar IS NULL OR order_type = $2) + AND ($3::varchar IS NULL OR status = $3) + "#, + ) + .bind(user_uuid) + .bind(order_type) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询支付订单 +pub async fn fetch_payment_order_by_uuid( + pool: &Pool, + order_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, PaymentOrderDto>( + r#" + SELECT id, uuid, order_no, user_uuid, order_type, amount, currency, status, + payment_channel, external_order_id, description, subscription_uuid, + coupon_uuid, original_amount, discount_amount, paid_at, refunded_at, + created_at, updated_at + FROM payment_orders + WHERE uuid = $1 + "#, + ) + .bind(order_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新订单状态 +pub async fn update_payment_order_status( + pool: &Pool, + order_uuid: Uuid, + status: &str, + external_order_id: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE payment_orders + SET status = $1, + external_order_id = COALESCE($2, external_order_id), + paid_at = CASE WHEN $1 = 'paid' THEN CURRENT_TIMESTAMP ELSE paid_at END, + updated_at = CURRENT_TIMESTAMP + WHERE uuid = $3 + "#, + ) + .bind(status) + .bind(external_order_id) + .bind(order_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Auto Renewal Services ============ + +/// 查询用户自动续费服务列表 +pub async fn fetch_auto_renewal_services( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, AutoRenewalServiceDto>( + r#" + SELECT id, uuid, user_uuid, service_type, service_uuid, service_name, + renewal_price, currency, next_bill_date, status, created_at, updated_at + FROM auto_renewal_services + WHERE user_uuid = $1 AND status = 'active' + ORDER BY next_bill_date ASC + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/server/src/models/environments.rs b/server/src/models/environments.rs new file mode 100644 index 00000000..92a45d75 --- /dev/null +++ b/server/src/models/environments.rs @@ -0,0 +1,1662 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{ + EnvironmentAccountRowDto, EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentDto, + EnvironmentRowDto, EnvironmentTagRowDto, EnvironmentUrlDto, GroupDto, GroupRowDto, ProxyRowDto, + TagDto, TemplateDto, +}; +use crate::entitys::CookieInput; + +// ============ Groups ============ + +/// 创建分组 +pub async fn insert_group( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + name: &str, + description: Option<&str>, + created_by: Uuid, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO groups (workspace_uuid, team_uuid, name, description, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(created_by) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询分组列表(工作空间级别) +pub async fn fetch_groups( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupDto>( + r#" + SELECT g.id, g.uuid, g.workspace_uuid, g.team_uuid, t.name AS team_name, + g.name, g.description, g.sort_order, + g.created_by, ui.nickname AS created_by_name, + (SELECT COUNT(*) FROM environments e WHERE e.group_uuid = g.uuid AND e.deleted_at IS NULL) AS environments_count, + g.created_at, g.updated_at, g.deleted_at + FROM groups g + LEFT JOIN teams t ON g.team_uuid = t.uuid + LEFT JOIN user_infos ui ON g.created_by = ui.user_uuid + WHERE g.workspace_uuid = $1 AND g.team_uuid = $2 AND g.deleted_at IS NULL + ORDER BY g.sort_order, g.name + LIMIT $3 OFFSET $4 + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据 UUID 查询分组 +pub async fn fetch_group_by_uuid( + pool: &Pool, + group_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, GroupDto>( + r#" + SELECT g.id, g.uuid, g.workspace_uuid, g.team_uuid, t.name AS team_name, + g.name, g.description, g.sort_order, + g.created_by, ui.nickname AS created_by_name, + (SELECT COUNT(*) FROM environments e WHERE e.group_uuid = g.uuid AND e.deleted_at IS NULL) AS environments_count, + g.created_at, g.updated_at, g.deleted_at + FROM groups g + LEFT JOIN teams t ON g.team_uuid = t.uuid + LEFT JOIN user_infos ui ON g.created_by = ui.user_uuid + WHERE g.uuid = $1 AND g.deleted_at IS NULL + "#, + ) + .bind(group_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新分组 +pub async fn update_group( + pool: &Pool, + group_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + sort_order: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE groups + SET name = COALESCE($1, name), + description = COALESCE($2, description), + sort_order = COALESCE($3, sort_order) + WHERE uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(sort_order) + .bind(group_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除分组 +pub async fn delete_group(pool: &Pool, group_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE groups SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(group_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Tags ============ + +/// 创建标签 +pub async fn insert_tag( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + name: &str, + color: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO tags (user_uuid, team_uuid, name, color) + VALUES ($1, $2, $3, $4) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(color.unwrap_or("gray")) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询标签列表 +pub async fn fetch_tags( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TagDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, color, sort_order, + environments_count, created_at, updated_at, deleted_at + FROM tags + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND deleted_at IS NULL + ORDER BY sort_order, name + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据 UUID 查询标签 +pub async fn fetch_tag_by_uuid( + pool: &Pool, + tag_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TagDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, color, sort_order, + environments_count, created_at, updated_at, deleted_at + FROM tags + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(tag_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新标签 +pub async fn update_tag( + pool: &Pool, + tag_uuid: Uuid, + name: Option<&str>, + color: Option<&str>, + sort_order: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE tags + SET name = COALESCE($1, name), + color = COALESCE($2, color), + sort_order = COALESCE($3, sort_order) + WHERE uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(color) + .bind(sort_order) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除标签 +pub async fn delete_tag(pool: &Pool, tag_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE tags SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Environments ============ + +/// 创建环境 +pub async fn insert_environment( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Uuid, + name: &str, + description: Option<&str>, + group_uuid: Option, + proxy_uuid: Option, + system_info: Option<&str>, + kernel_info: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO environments (workspace_uuid, user_uuid, team_uuid, name, description, + group_uuid, proxy_uuid, system_info, kernel_info) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(group_uuid) + .bind(proxy_uuid) + .bind(system_info) + .bind(kernel_info) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询环境列表(基础信息) +pub async fn fetch_environments_base( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + status: Option<&str>, + keyword: Option<&str>, + tag_uuids: Option<&[Uuid]>, + offset: i64, + limit: i64, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT DISTINCT + e.id, e.uuid, e.workspace_uuid, e.user_uuid, e.team_uuid, e.name, e.description, e.status, + e.system_info, e.kernel_info, e.fingerprint_summary, + e.group_uuid, e.proxy_uuid, + e.last_opened_at, e.created_at, e.updated_at + FROM environments e + "#, + ); + + // 如果有标签过滤,需要 JOIN environment_tags 表 + if tag_uuids.is_some() { + query.push_str(" LEFT JOIN environment_tags et ON e.uuid = et.environment_uuid "); + } + + query.push_str( + r#" + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + // 分组过滤 + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + // 状态过滤 + if status.is_some() { + conditions.push(format!("e.status = ${}", param_index)); + param_index += 1; + } + + // 关键词搜索 + if keyword.is_some() { + conditions.push(format!( + "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + // 标签过滤 + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + conditions.push(format!("et.tag_uuid = ANY(${})", param_index)); + param_index += 1; + } + } + + // 添加所有条件 + for condition in conditions { + query.push_str(&format!(" AND {}", condition)); + } + + query.push_str(&format!( + " ORDER BY e.created_at DESC LIMIT ${} OFFSET ${}", + param_index, + param_index + 1 + )); + + // 构建查询 + let mut sql_query = sqlx::query_as::<_, EnvironmentRowDto>(&query) + .bind(workspace_uuid) + .bind(team_uuid); + + // 绑定参数 + if let Some(g) = group_uuid { + sql_query = sql_query.bind(g); + } + if let Some(s) = status { + sql_query = sql_query.bind(s); + } + if let Some(k) = keyword { + let search_pattern = format!("%{}%", k); + sql_query = sql_query.bind(search_pattern); + } + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + sql_query = sql_query.bind(tags); + } + } + + sql_query = sql_query.bind(limit).bind(offset); + + let recs = sql_query.fetch_all(pool).await?; + + Ok(recs) +} + +/// 查询环境列表(基础) +pub async fn fetch_environments( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, EnvironmentDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, + status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, + last_opened_at, created_at, updated_at, deleted_at + FROM environments + WHERE workspace_uuid = $1 AND team_uuid = $2 + AND ($3::uuid IS NULL OR group_uuid = $3) + AND ($4::varchar IS NULL OR status = $4) + AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT $5 OFFSET $6 + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(group_uuid) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询环境总数 +pub async fn fetch_environments_count( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + status: Option<&str>, + keyword: Option<&str>, + tag_uuids: Option<&[Uuid]>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(DISTINCT e.id) FROM environments e + "#, + ); + + // 如果有标签过滤,需要 JOIN environment_tags 表 + if tag_uuids.is_some() { + query.push_str(" LEFT JOIN environment_tags et ON e.uuid = et.environment_uuid "); + } + + query.push_str( + r#" + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + // 分组过滤 + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + // 状态过滤 + if status.is_some() { + conditions.push(format!("e.status = ${}", param_index)); + param_index += 1; + } + + // 关键词搜索 + if keyword.is_some() { + conditions.push(format!( + "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + // 标签过滤 + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + conditions.push(format!("et.tag_uuid = ANY(${})", param_index)); + } + } + + // 添加所有条件 + for condition in conditions { + query.push_str(&format!(" AND {}", condition)); + } + + // 构建查询 + let mut sql_query = sqlx::query_scalar::<_, i64>(&query).bind(workspace_uuid).bind(team_uuid); + + // 绑定参数 + if let Some(g) = group_uuid { + sql_query = sql_query.bind(g); + } + if let Some(s) = status { + sql_query = sql_query.bind(s); + } + if let Some(k) = keyword { + let search_pattern = format!("%{}%", k); + sql_query = sql_query.bind(search_pattern); + } + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + sql_query = sql_query.bind(tags); + } + } + + let count = sql_query.fetch_one(pool).await?; + + Ok(count) +} + +/// 根据 UUID 查询环境(不带工作空间过滤,用于内部查询) +pub async fn fetch_environment_by_uuid_unfiltered( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, EnvironmentDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, + status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, + last_opened_at, created_at, updated_at, deleted_at + FROM environments + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 根据 UUID 查询环境(带工作空间过滤) +pub async fn fetch_environment_by_uuid( + pool: &Pool, + workspace_uuid: Uuid, + env_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, EnvironmentDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, + status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, + last_opened_at, created_at, updated_at, deleted_at + FROM environments + WHERE uuid = $1 AND workspace_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新环境基础信息 +pub async fn update_environment( + pool: &Pool, + env_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + group_uuid: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments + SET name = COALESCE($1, name), + description = COALESCE($2, description), + group_uuid = $3 + WHERE uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(group_uuid) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新环境状态 +pub async fn update_environment_status( + pool: &Pool, + env_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET status = $1 + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新环境代理 +pub async fn update_environment_proxy( + pool: &Pool, + env_uuid: Uuid, + proxy_uuid: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET proxy_uuid = $1 + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新环境最后打开时间 +pub async fn update_environment_last_opened( + pool: &Pool, + env_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET last_opened_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除环境 +pub async fn delete_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除环境 +pub async fn batch_delete_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE environments SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(env_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Recycle Bin ============ + +/// 查询回收站环境列表(已删除但未永久删除) +pub async fn fetch_deleted_environments_base( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + keyword: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT DISTINCT + e.id, e.uuid, e.workspace_uuid, e.user_uuid, e.team_uuid, e.name, e.description, e.status, + e.system_info, e.kernel_info, e.fingerprint_summary, + e.group_uuid, e.proxy_uuid, + e.last_opened_at, e.created_at, e.updated_at, e.deleted_at + FROM environments e + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NOT NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + // 分组过滤 + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + // 关键词搜索 + if keyword.is_some() { + conditions.push(format!( + "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + if !conditions.is_empty() { + query.push_str(" AND "); + query.push_str(&conditions.join(" AND ")); + } + + query.push_str(" ORDER BY e.deleted_at DESC LIMIT $"); + query.push_str(¶m_index.to_string()); + param_index += 1; + query.push_str(" OFFSET $"); + query.push_str(¶m_index.to_string()); + + let mut q = sqlx::query_as::<_, EnvironmentRowDto>(&query) + .bind(workspace_uuid) + .bind(team_uuid); + + if let Some(gid) = group_uuid { + q = q.bind(gid); + } + + if let Some(kw) = keyword { + let pattern = format!("%{}%", kw); + q = q.bind(pattern); + } + + q = q.bind(limit).bind(offset); + + let recs = q.fetch_all(pool).await?; + Ok(recs) +} + +/// 统计回收站环境总数 +pub async fn fetch_deleted_environments_count( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + keyword: Option<&str>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(DISTINCT e.id) + FROM environments e + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NOT NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + if keyword.is_some() { + conditions.push(format!( + "(e.name ILIKE ${} OR e.uuid::text ILIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + if !conditions.is_empty() { + query.push_str(" AND "); + query.push_str(&conditions.join(" AND ")); + } + + let mut q = sqlx::query_scalar::<_, i64>(&query).bind(workspace_uuid).bind(team_uuid); + + if let Some(gid) = group_uuid { + q = q.bind(gid); + } + + if let Some(kw) = keyword { + let pattern = format!("%{}%", kw); + q = q.bind(pattern); + } + + let count = q.fetch_one(pool).await?; + Ok(count) +} + +/// 恢复环境(将 deleted_at 设为 NULL) +pub async fn restore_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET deleted_at = NULL + WHERE uuid = $1 AND deleted_at IS NOT NULL + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量恢复环境 +pub async fn batch_restore_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE environments SET deleted_at = NULL + WHERE uuid = ANY($1) AND deleted_at IS NOT NULL + "#, + ) + .bind(env_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +/// 永久删除环境(真正的 DELETE) +pub async fn permanent_delete_environment( + pool: &Pool, + env_uuid: Uuid, +) -> Result<(), Error> { + // 先删除关联数据 + sqlx::query("DELETE FROM environment_tags WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_urls WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_cookies WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_configs WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_accounts WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + // `environment_extensions` 已在扩展系统重构后移除。 + // 环境的扩展现在通过 user/team/group 绑定动态合并,不再需要清理环境级关联。 + + // 最后删除环境本身 + sqlx::query("DELETE FROM environments WHERE uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量永久删除环境 +pub async fn batch_permanent_delete_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result { + // 先删除关联数据 + sqlx::query("DELETE FROM environment_tags WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_urls WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_cookies WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_configs WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_accounts WHERE environment_uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + // `environment_extensions` 已在扩展系统重构后移除。 + // 环境的扩展现在通过 user/team/group 绑定动态合并,不再需要清理环境级关联。 + + // 最后删除环境本身 + let result = sqlx::query("DELETE FROM environments WHERE uuid = ANY($1)") + .bind(env_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Environment Configs ============ + +/// 创建或更新环境配置 +pub async fn upsert_environment_config( + pool: &Pool, + env_uuid: Uuid, + window_info: &serde_json::Value, + basic_settings: &serde_json::Value, + fingerprint_settings: &serde_json::Value, + device_settings: &serde_json::Value, + preference_settings: &serde_json::Value, + project_metadata: &serde_json::Value, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO environment_configs (environment_uuid, window_info, basic_settings, + fingerprint_settings, device_settings, preference_settings, + project_metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (environment_uuid) DO UPDATE SET + window_info = $2, + basic_settings = $3, + fingerprint_settings = $4, + device_settings = $5, + preference_settings = $6, + project_metadata = $7 + RETURNING id; + "#, + ) + .bind(env_uuid) + .bind(window_info) + .bind(basic_settings) + .bind(fingerprint_settings) + .bind(device_settings) + .bind(preference_settings) + .bind(project_metadata) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询环境配置 +pub async fn fetch_environment_config( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, EnvironmentConfigDto>( + r#" + SELECT id, environment_uuid, window_info, basic_settings, fingerprint_settings, + device_settings, preference_settings, project_metadata, created_at, updated_at + FROM environment_configs + WHERE environment_uuid = $1 + "#, + ) + .bind(env_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 批量查询环境配置 +pub async fn fetch_environment_configs_by_uuids( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentConfigDto>( + r#" + SELECT id, environment_uuid, window_info, basic_settings, fingerprint_settings, + device_settings, preference_settings, project_metadata, created_at, updated_at + FROM environment_configs + WHERE environment_uuid = ANY($1) + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +// ============ Environment Tags ============ + +/// 为环境添加标签 +pub async fn insert_environment_tag( + pool: &Pool, + env_uuid: Uuid, + tag_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO environment_tags (environment_uuid, tag_uuid) + VALUES ($1, $2) + ON CONFLICT DO NOTHING; + "#, + ) + .bind(env_uuid) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除环境的标签 +pub async fn remove_environment_tag( + pool: &Pool, + env_uuid: Uuid, + tag_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_tags + WHERE environment_uuid = $1 AND tag_uuid = $2; + "#, + ) + .bind(env_uuid) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清除环境的所有标签 +pub async fn clear_environment_tags(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_tags + WHERE environment_uuid = $1; + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询环境的所有标签 +pub async fn fetch_environment_tags( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TagDto>( + r#" + SELECT t.id, t.uuid, t.user_uuid, t.team_uuid, t.name, t.color, t.sort_order, + t.environments_count, t.created_at, t.updated_at, t.deleted_at + FROM tags t + INNER JOIN environment_tags et ON t.uuid = et.tag_uuid + WHERE et.environment_uuid = $1 AND t.deleted_at IS NULL + ORDER BY t.sort_order, t.name + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询环境标签(完整标签信息) +pub async fn fetch_tags_for_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentTagRowDto>( + r#" + SELECT + et.environment_uuid, + t.id as tag_id, + t.uuid as tag_uuid, + t.name as tag_name, + t.color as tag_color, + t.sort_order as tag_sort_order, + t.user_uuid as tag_user_uuid, + t.team_uuid as tag_team_uuid, + t.environments_count as tag_environments_count, + t.created_at as tag_created_at, + t.updated_at as tag_updated_at, + t.deleted_at as tag_deleted_at + FROM environment_tags et + INNER JOIN tags t ON et.tag_uuid = t.uuid + WHERE et.environment_uuid = ANY($1) AND t.deleted_at IS NULL + ORDER BY t.sort_order, t.name + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询环境账号(完整账号信息,排除敏感数据) +pub async fn fetch_accounts_for_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentAccountRowDto>( + r#" + SELECT + ea.environment_uuid, + pa.id as account_id, + pa.uuid as account_uuid, + pa.platform_url, + pa.platform_name, + pa.account, + pa.status as account_status, + pa.remark + FROM environment_accounts ea + INNER JOIN platform_accounts pa ON ea.account_uuid = pa.uuid + WHERE ea.environment_uuid = ANY($1) AND pa.deleted_at IS NULL + ORDER BY ea.sort_order + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询分组 +pub async fn fetch_groups_by_uuids( + pool: &Pool, + group_uuids: &[Uuid], +) -> Result, Error> { + if group_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, GroupRowDto>( + r#" + SELECT id, uuid, name, description, sort_order + FROM groups + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(group_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询代理 +pub async fn fetch_proxies_by_uuids( + pool: &Pool, + proxy_uuids: &[Uuid], +) -> Result, Error> { + if proxy_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, ProxyRowDto>( + r#" + SELECT id, uuid, name, host, port, proxy_type, + username, password, + country, city, status, latency, last_check_ip + FROM proxies + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +// ============ Templates ============ + +/// 创建模板 +pub async fn insert_template( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + name: &str, + description: Option<&str>, + is_public: bool, + system_info: Option<&str>, + kernel_info: Option<&str>, + config_json: &serde_json::Value, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO templates (user_uuid, team_uuid, name, description, is_public, + system_info, kernel_info, config_json) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(is_public) + .bind(system_info) + .bind(kernel_info) + .bind(config_json) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询模板列表 +pub async fn fetch_templates( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + is_public: Option, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TemplateDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, is_public, + system_info, kernel_info, config_json, usage_count, created_at, updated_at, deleted_at + FROM templates + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2) OR is_public = TRUE) + AND ($3::boolean IS NULL OR is_public = $3) + AND deleted_at IS NULL + ORDER BY usage_count DESC, created_at DESC + LIMIT $4 OFFSET $5 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(is_public) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询模板总数 +pub async fn fetch_templates_count( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + is_public: Option, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM templates + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2) OR is_public = TRUE) + AND ($3::boolean IS NULL OR is_public = $3) + AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(is_public) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询模板 +pub async fn fetch_template_by_uuid( + pool: &Pool, + template_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TemplateDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, is_public, + system_info, kernel_info, config_json, usage_count, created_at, updated_at, deleted_at + FROM templates + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(template_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新模板 +pub async fn update_template( + pool: &Pool, + template_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + is_public: Option, + config_json: Option<&serde_json::Value>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE templates + SET name = COALESCE($1, name), + description = COALESCE($2, description), + is_public = COALESCE($3, is_public), + config_json = COALESCE($4, config_json) + WHERE uuid = $5 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(is_public) + .bind(config_json) + .bind(template_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加模板使用次数 +pub async fn increment_template_usage( + pool: &Pool, + template_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE templates SET usage_count = usage_count + 1 + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(template_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除模板 +pub async fn delete_template(pool: &Pool, template_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE templates SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(template_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Environment URLs ============ + +/// 添加环境 URL +pub async fn insert_environment_url( + pool: &Pool, + env_uuid: Uuid, + url: &str, + title: Option<&str>, + sort_order: Option, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO environment_urls (environment_uuid, url, title, sort_order) + VALUES ($1, $2, $3, $4) + RETURNING id; + "#, + ) + .bind(env_uuid) + .bind(url) + .bind(title) + .bind(sort_order.unwrap_or(0)) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 批量添加环境 URL +pub async fn batch_insert_environment_urls( + pool: &Pool, + env_uuid: Uuid, + urls: &[(String, Option)], +) -> Result { + let mut count = 0; + for (idx, (url, title)) in urls.iter().enumerate() { + sqlx::query( + r#" + INSERT INTO environment_urls (environment_uuid, url, title, sort_order) + VALUES ($1, $2, $3, $4) + ON CONFLICT DO NOTHING; + "#, + ) + .bind(env_uuid) + .bind(url) + .bind(title.as_deref()) + .bind(idx as i32) + .execute(pool) + .await?; + count += 1; + } + Ok(count) +} + +/// 查询环境的所有 URL +pub async fn fetch_environment_urls( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, EnvironmentUrlDto>( + r#" + SELECT id, environment_uuid, url, title, sort_order, created_at + FROM environment_urls + WHERE environment_uuid = $1 + ORDER BY sort_order, id + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询环境的所有 URL +pub async fn fetch_environment_urls_by_uuids( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentUrlDto>( + r#" + SELECT id, environment_uuid, url, title, sort_order, created_at + FROM environment_urls + WHERE environment_uuid = ANY($1) + ORDER BY environment_uuid, sort_order, id + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除环境的 URL +pub async fn delete_environment_url(pool: &Pool, url_id: i32) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_urls WHERE id = $1 + "#, + ) + .bind(url_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清空环境的所有 URL +pub async fn clear_environment_urls(pool: &Pool, env_uuid: Uuid) -> Result { + let result = sqlx::query( + r#" + DELETE FROM environment_urls WHERE environment_uuid = $1 + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie +pub async fn insert_environment_cookie( + pool: &Pool, + env_uuid: Uuid, + site_input: &str, + domain: &str, + name: &str, + value: &str, + path: Option<&str>, + http_only: Option, + secure: Option, + same_site: Option<&str>, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO environment_cookies (environment_uuid, site_input, domain, name, value, path, http_only, secure, same_site) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id; + "#, + ) + .bind(env_uuid) + .bind(site_input) + .bind(domain) + .bind(name) + .bind(value) + .bind(path.unwrap_or("/")) + .bind(http_only.unwrap_or(false)) + .bind(secure.unwrap_or(false)) + .bind(same_site.unwrap_or("Lax")) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 批量添加环境 Cookies +pub async fn batch_insert_environment_cookies( + pool: &Pool, + env_uuid: Uuid, + cookies: &[CookieInput], +) -> Result { + let mut count = 0; + for cookie in cookies { + sqlx::query( + r#" + INSERT INTO environment_cookies (environment_uuid, site_input, domain, name, value, path, http_only, secure, same_site) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT DO NOTHING; + "#, + ) + .bind(env_uuid) + .bind(&cookie.site_input) + .bind(&cookie.domain) + .bind(&cookie.name) + .bind(&cookie.value) + .bind(cookie.path.as_deref().unwrap_or("/")) + .bind(cookie.http_only.unwrap_or(false)) + .bind(cookie.secure.unwrap_or(false)) + .bind(cookie.same_site.as_deref().unwrap_or("Lax")) + .execute(pool) + .await?; + count += 1; + } + Ok(count) +} + +/// 查询环境的所有 Cookies +pub async fn fetch_environment_cookies( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, EnvironmentCookieDto>( + r#" + SELECT id, environment_uuid, site_input, domain, name, value, path, expires_at, http_only, secure, same_site, created_at + FROM environment_cookies + WHERE environment_uuid = $1 + ORDER BY site_input, domain, name + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询环境的所有 Cookies +pub async fn fetch_environment_cookies_by_uuids( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let recs = sqlx::query_as::<_, EnvironmentCookieDto>( + r#" + SELECT id, environment_uuid, site_input, domain, name, value, path, expires_at, http_only, secure, same_site, created_at + FROM environment_cookies + WHERE environment_uuid = ANY($1) + ORDER BY environment_uuid, site_input, domain, name + "#, + ) + .bind(env_uuids) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除环境的 Cookie +pub async fn delete_environment_cookie(pool: &Pool, cookie_id: i32) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_cookies WHERE id = $1 + "#, + ) + .bind(cookie_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清空环境的所有 Cookies +pub async fn clear_environment_cookies( + pool: &Pool, + env_uuid: Uuid, +) -> Result { + let result = sqlx::query( + r#" + DELETE FROM environment_cookies WHERE environment_uuid = $1 + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} diff --git a/server/src/models/extensions.rs b/server/src/models/extensions.rs new file mode 100644 index 00000000..b189eb4c --- /dev/null +++ b/server/src/models/extensions.rs @@ -0,0 +1,614 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::entitys::extensions::{CreateExtensionParams, UpdateExtensionParams}; +use crate::dto::{ + ExtensionDto, GroupExtensionDto, TeamExtensionDto, UserExtensionDto, +}; + +// ============ Extensions CRUD ============ + +/// 创建扩展 +pub async fn create_extension( + pool: &Pool, + params: CreateExtensionParams, +) -> Result { + let rec = sqlx::query_as::<_, ExtensionDto>( + r#" + INSERT INTO extensions ( + extension_id, name, description, version, category, browser, + developer, homepage, icon_url, download_url, file_size, downloads_count, + permissions, rating, changelog, published_at, hash, status + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 'active') + RETURNING id, uuid, extension_id, name, description, version, category, browser, + developer, homepage, icon_url, download_url, file_size, downloads_count, + rating, permissions, status, changelog, published_at, hash, created_at, updated_at + "#, + ) + .bind(¶ms.extension_id) + .bind(¶ms.name) + .bind(¶ms.description) + .bind(¶ms.version) + .bind(¶ms.category) + .bind(¶ms.browser) + .bind(¶ms.developer) + .bind(¶ms.homepage) + .bind(¶ms.icon_url) + .bind(¶ms.download_url) + .bind(params.file_size) + .bind(params.downloads_count) + .bind(¶ms.permissions) + .bind(params.rating) + .bind(¶ms.changelog) + .bind(params.published_at) + .bind(¶ms.hash) + .fetch_one(pool) + .await?; + + Ok(rec) +} + +/// 更新扩展 +pub async fn update_extension( + pool: &Pool, + extension_id: &str, + params: UpdateExtensionParams, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE extensions SET + name = COALESCE($2, name), + description = COALESCE($3, description), + version = COALESCE($4, version), + category = COALESCE($5, category), + developer = COALESCE($6, developer), + homepage = COALESCE($7, homepage), + icon_url = COALESCE($8, icon_url), + download_url = COALESCE($9, download_url), + file_size = COALESCE($10, file_size), + downloads_count = COALESCE($11, downloads_count), + permissions = COALESCE($12, permissions), + rating = COALESCE($13, rating), + changelog = COALESCE($14, changelog), + published_at = COALESCE($15, published_at), + hash = COALESCE($16, hash), + updated_at = CURRENT_TIMESTAMP + WHERE extension_id = $1 + "#, + ) + .bind(extension_id) + .bind(params.name) + .bind(params.description) + .bind(params.version) + .bind(params.category) + .bind(params.developer) + .bind(params.homepage) + .bind(params.icon_url) + .bind(params.download_url) + .bind(params.file_size) + .bind(params.downloads_count) + .bind(params.permissions) + .bind(params.rating) + .bind(params.changelog) + .bind(params.published_at) + .bind(params.hash) + .execute(pool) + .await?; + + Ok(()) +} + +/// 根据 extension_id 获取扩展(别名函数,用于同步检查) +pub async fn get_extension_by_extension_id( + pool: &Pool, + extension_id: &str, +) -> Result, Error> { + fetch_extension_by_id(pool, extension_id).await +} + +// ============ Extensions Query ============ + +/// 查询扩展列表 +pub async fn fetch_extensions( + pool: &Pool, + keyword: Option<&str>, + category: Option<&str>, + sort_by: Option<&str>, + sort_order: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let order_clause = match ( + sort_by.unwrap_or("downloads"), + sort_order.unwrap_or("desc").to_ascii_lowercase().as_str(), + ) { + ("rating", "asc") => "rating ASC NULLS LAST, created_at DESC", + ("rating", _) => "rating DESC NULLS LAST, created_at DESC", + ("name", "asc") => "name ASC, created_at DESC", + ("name", _) => "name DESC, created_at DESC", + ("newest", "asc") => "updated_at ASC NULLS LAST, created_at ASC", + ("newest", _) => "updated_at DESC NULLS LAST, created_at DESC", + ("downloads", "asc") => "downloads_count ASC NULLS LAST, created_at DESC", + _ => "downloads_count DESC NULLS LAST, created_at DESC", + }; + + let query = format!( + r#" + SELECT id, uuid, extension_id, name, description, version, category, browser, + developer, homepage, icon_url, download_url, file_size, downloads_count, + rating, permissions, status, changelog, published_at, hash, created_at, updated_at + FROM extensions + WHERE status = 'active' + AND ($1::varchar IS NULL OR name ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%') + AND ($2::varchar IS NULL OR category = $2) + ORDER BY {} + LIMIT $3 OFFSET $4 + "#, + order_clause + ); + + let recs = sqlx::query_as::<_, ExtensionDto>(&query) + .bind(keyword) + .bind(category) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询扩展总数 +pub async fn fetch_extensions_count( + pool: &Pool, + keyword: Option<&str>, + category: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM extensions + WHERE status = 'active' + AND ($1::varchar IS NULL OR name ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%') + AND ($2::varchar IS NULL OR category = $2) + "#, + ) + .bind(keyword) + .bind(category) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 extension_id 查询扩展 +pub async fn fetch_extension_by_id( + pool: &Pool, + extension_id: &str, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ExtensionDto>( + r#" + SELECT id, uuid, extension_id, name, description, version, category, browser, + developer, homepage, icon_url, download_url, file_size, downloads_count, + rating, permissions, status, changelog, published_at, hash, created_at, updated_at + FROM extensions + WHERE extension_id = $1 + "#, + ) + .bind(extension_id) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 获取扩展分类列表 +pub async fn fetch_extension_categories(pool: &Pool) -> Result, Error> { + let categories: Vec<(String,)> = sqlx::query_as( + r#" + SELECT DISTINCT category FROM extensions + WHERE status = 'active' + ORDER BY category + "#, + ) + .fetch_all(pool) + .await?; + + Ok(categories.into_iter().map(|(c,)| c).collect()) +} + +// ============ User Extensions ============ + +/// 查询用户已安装的扩展 +pub async fn fetch_user_extensions( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, UserExtensionDto>( + r#" + SELECT id, user_uuid, extension_id, installed_version, status, installed_at, updated_at + FROM user_extensions + WHERE user_uuid = $1 AND status = 'active' + ORDER BY installed_at DESC + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 安装用户扩展 +pub async fn insert_user_extension( + pool: &Pool, + user_uuid: Uuid, + extension_id: &str, + version: &str, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO user_extensions (user_uuid, extension_id, installed_version, status) + VALUES ($1, $2, $3, 'active') + ON CONFLICT (user_uuid, extension_id) DO UPDATE SET + installed_version = $3, + status = 'active', + updated_at = CURRENT_TIMESTAMP + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(extension_id) + .bind(version) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 卸载用户扩展 +pub async fn delete_user_extension( + pool: &Pool, + user_uuid: Uuid, + extension_id: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $1 AND extension_id = $2 + "#, + ) + .bind(user_uuid) + .bind(extension_id) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Team Extensions ============ + +/// 查询团队已安装的扩展 +pub async fn fetch_team_extensions( + pool: &Pool, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TeamExtensionDto>( + r#" + SELECT id, team_uuid, extension_id, installed_version, installed_by, status, installed_at, updated_at + FROM team_extensions + WHERE team_uuid = $1 AND status = 'active' + ORDER BY installed_at DESC + "#, + ) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 安装团队扩展 +pub async fn insert_team_extension( + pool: &Pool, + team_uuid: Uuid, + extension_id: &str, + version: &str, + installed_by: Uuid, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO team_extensions (team_uuid, extension_id, installed_version, installed_by, status) + VALUES ($1, $2, $3, $4, 'active') + ON CONFLICT (team_uuid, extension_id) DO UPDATE SET + installed_version = $3, + installed_by = $4, + status = 'active', + updated_at = CURRENT_TIMESTAMP + RETURNING id; + "#, + ) + .bind(team_uuid) + .bind(extension_id) + .bind(version) + .bind(installed_by) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 卸载团队扩展 +pub async fn delete_team_extension( + pool: &Pool, + team_uuid: Uuid, + extension_id: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP + WHERE team_uuid = $1 AND extension_id = $2 + "#, + ) + .bind(team_uuid) + .bind(extension_id) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Group Extensions ============ + +/// 查询分组已安装的扩展 +pub async fn fetch_group_extensions( + pool: &Pool, + group_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupExtensionDto>( + r#" + SELECT id, group_uuid, extension_id, installed_version, installed_by, status, is_team_shared, installed_at, updated_at + FROM group_extensions + WHERE group_uuid = $1 AND status = 'active' + ORDER BY installed_at DESC + "#, + ) + .bind(group_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 安装分组扩展 +pub async fn insert_group_extension( + pool: &Pool, + group_uuid: Uuid, + extension_id: &str, + version: &str, + installed_by: Uuid, + is_team_shared: bool, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO group_extensions (group_uuid, extension_id, installed_version, installed_by, is_team_shared, status) + VALUES ($1, $2, $3, $4, $5, 'active') + ON CONFLICT (group_uuid, extension_id) DO UPDATE SET + installed_version = $3, + installed_by = $4, + is_team_shared = $5, + status = 'active', + updated_at = CURRENT_TIMESTAMP + RETURNING id; + "#, + ) + .bind(group_uuid) + .bind(extension_id) + .bind(version) + .bind(installed_by) + .bind(is_team_shared) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 卸载分组扩展(指定分组) +pub async fn delete_group_extension( + pool: &Pool, + group_uuid: Uuid, + extension_id: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE group_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP + WHERE group_uuid = $1 AND extension_id = $2 + "#, + ) + .bind(group_uuid) + .bind(extension_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// 卸载分组扩展(根据扩展 ID 删除所有相关分组记录) +pub async fn delete_group_extensions_by_extension_id( + pool: &Pool, + extension_id: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE group_extensions SET status = 'inactive', updated_at = CURRENT_TIMESTAMP + WHERE extension_id = $1 AND status = 'active' + "#, + ) + .bind(extension_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// 根据扩展 ID 查询所有关联的分组 UUID +pub async fn fetch_group_uuids_by_extension_id( + pool: &Pool, + extension_id: &str, +) -> Result, Error> { + let recs = sqlx::query_scalar::<_, Uuid>( + r#" + SELECT DISTINCT group_uuid + FROM group_extensions + WHERE extension_id = $1 AND status = 'active' + "#, + ) + .bind(extension_id) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据扩展 ID 查询团队共享的分组 UUID +pub async fn fetch_team_shared_group_uuids_by_extension_id( + pool: &Pool, + extension_id: &str, +) -> Result, Error> { + let recs = sqlx::query_scalar::<_, Uuid>( + r#" + SELECT DISTINCT group_uuid + FROM group_extensions + WHERE extension_id = $1 AND status = 'active' AND is_team_shared = true + "#, + ) + .bind(extension_id) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询用户相关的分组中安装的扩展 +/// 包括用户个人分组(无 team_uuid,由用户创建)和用户所在团队的分组 +pub async fn fetch_user_group_extensions( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupExtensionDto>( + r#" + SELECT ge.id, ge.group_uuid, ge.extension_id, ge.installed_version, ge.installed_by, ge.status, ge.is_team_shared, ge.installed_at, ge.updated_at + FROM group_extensions ge + INNER JOIN groups g ON ge.group_uuid = g.uuid + WHERE ge.status = 'active' + AND g.deleted_at IS NULL + AND (g.team_uuid = $1 OR (g.team_uuid IS NULL AND g.created_by = $2)) + ORDER BY ge.installed_at DESC + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询团队相关的分组中安装的扩展 +pub async fn fetch_team_group_extensions( + pool: &Pool, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupExtensionDto>( + r#" + SELECT ge.id, ge.group_uuid, ge.extension_id, ge.installed_version, ge.installed_by, ge.status, ge.is_team_shared, ge.installed_at, ge.updated_at + FROM group_extensions ge + INNER JOIN groups g ON ge.group_uuid = g.uuid + WHERE ge.status = 'active' + AND ge.is_team_shared = true + AND g.deleted_at IS NULL + AND g.team_uuid = $1 + ORDER BY ge.installed_at DESC + "#, + ) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +// ============ User Team Extension Preferences ============ + +/// 查询用户禁用的团队插件列表 +pub async fn fetch_user_disabled_team_extensions( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_scalar::<_, String>( + r#" + SELECT extension_id + FROM user_team_extension_preferences + WHERE user_uuid = $1 AND team_uuid = $2 AND is_disabled = true + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 设置用户对团队插件的禁用状态 +pub async fn set_user_team_extension_preference( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Uuid, + extension_id: &str, + is_disabled: bool, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_team_extension_preferences (user_uuid, team_uuid, extension_id, is_disabled) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_uuid, team_uuid, extension_id) DO UPDATE SET + is_disabled = $4, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(extension_id) + .bind(is_disabled) + .execute(pool) + .await?; + + Ok(()) +} + +/// 删除用户对团队插件的偏好设置 +pub async fn delete_user_team_extension_preference( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Uuid, + extension_id: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM user_team_extension_preferences + WHERE user_uuid = $1 AND team_uuid = $2 AND extension_id = $3 + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(extension_id) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/server/src/models/group_member_permissions.rs b/server/src/models/group_member_permissions.rs new file mode 100644 index 00000000..2922ea4d --- /dev/null +++ b/server/src/models/group_member_permissions.rs @@ -0,0 +1,199 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::GroupMemberPermissionDto; + +/// 授予分组权限 +pub async fn grant_group_permission( + pool: &Pool, + group_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + permission_type: &str, + granted_by: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO group_member_permissions ( + group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by + ) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (group_uuid, user_uuid) DO UPDATE SET + permission_type = EXCLUDED.permission_type, + granted_by = EXCLUDED.granted_by, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(user_uuid) + .bind(permission_type) + .bind(granted_by) + .execute(pool) + .await?; + + Ok(()) +} + +/// 撤销分组权限 +pub async fn revoke_group_permission( + pool: &Pool, + group_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM group_member_permissions + WHERE group_uuid = $1 AND user_uuid = $2 + "#, + ) + .bind(group_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询用户的分组权限列表 +pub async fn fetch_user_group_permissions( + pool: &Pool, + user_uuid: Uuid, + workspace_uuid: Option, + group_uuid: Option, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupMemberPermissionDto>( + r#" + SELECT group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by, + created_at, updated_at + FROM group_member_permissions + WHERE user_uuid = $1 + AND ($2::uuid IS NULL OR workspace_uuid = $2) + AND ($3::uuid IS NULL OR group_uuid = $3) + ORDER BY created_at DESC + "#, + ) + .bind(user_uuid) + .bind(workspace_uuid) + .bind(group_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 检查用户是否有分组权限(工作空间级别) +pub async fn check_group_permission( + pool: &Pool, + workspace_uuid: Uuid, + group_uuid: Uuid, + user_uuid: Uuid, + permission_type: &str, +) -> Result { + // 首先检查用户是否是团队成员,以及是否是 Owner/Admin(自动拥有所有权限) + let is_owner_or_admin: bool = sqlx::query_scalar( + r#" + SELECT EXISTS ( + SELECT 1 FROM team_members tm + INNER JOIN groups g ON tm.team_uuid = g.team_uuid AND tm.workspace_uuid = g.workspace_uuid + WHERE g.uuid = $1 + AND g.workspace_uuid = $2 + AND tm.workspace_uuid = $2 + AND tm.user_uuid = $3 + AND tm.role IN ('owner', 'admin') + AND tm.deleted_at IS NULL + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + if is_owner_or_admin { + return Ok(true); + } + + // 检查显式权限(工作空间级别) + let has_permission = match permission_type { + "read" => { + // read 权限:检查是否有 read/write/manage 任一权限 + sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 FROM group_member_permissions + WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await? + } + "write" => { + // write 权限:检查是否有 write/manage 权限 + sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 FROM group_member_permissions + WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 + AND permission_type IN ('write', 'manage') + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await? + } + "manage" => { + // manage 权限:检查是否有 manage 权限 + sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 FROM group_member_permissions + WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 + AND permission_type = 'manage' + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await? + } + _ => false, + }; + + Ok(has_permission) +} + +/// 查询分组的所有权限 +pub async fn fetch_group_permissions( + pool: &Pool, + group_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupMemberPermissionDto>( + r#" + SELECT group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by, + created_at, updated_at + FROM group_member_permissions + WHERE group_uuid = $1 + ORDER BY created_at DESC + "#, + ) + .bind(group_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/server/src/models/local_api.rs b/server/src/models/local_api.rs new file mode 100644 index 00000000..16601e3d --- /dev/null +++ b/server/src/models/local_api.rs @@ -0,0 +1,343 @@ +use chrono::{DateTime, Utc}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{ + LocalApiKeyDto, LocalApiKeyPermissionDto, LocalApiPermissionDefinitionDto, LocalApiSettingsDto, +}; + +pub async fn fetch_local_api_settings( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiSettingsDto>( + r#" + SELECT id, uuid, user_uuid, enabled, port, remote_access, cors_origins, created_at, updated_at, deleted_at + FROM user_local_api_settings + WHERE user_uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await +} + +pub async fn upsert_local_api_settings( + pool: &Pool, + user_uuid: Uuid, + enabled: Option, + port: Option, + remote_access: Option, + cors_origins: Option<&Value>, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_local_api_settings (user_uuid, enabled, port, remote_access, cors_origins) + VALUES ($1, COALESCE($2, FALSE), COALESCE($3, 8080), COALESCE($4, FALSE), COALESCE($5, '[]'::jsonb)) + ON CONFLICT (user_uuid) DO UPDATE SET + enabled = COALESCE($2, user_local_api_settings.enabled), + port = COALESCE($3, user_local_api_settings.port), + remote_access = COALESCE($4, user_local_api_settings.remote_access), + cors_origins = COALESCE($5, user_local_api_settings.cors_origins), + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(enabled) + .bind(port) + .bind(remote_access) + .bind(cors_origins) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn fetch_active_api_key( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiKeyDto>( + r#" + SELECT + id, uuid, user_uuid, key_prefix, key_hash, api_key, is_active, requests_today, + daily_limit, last_reset_date, last_used_at, expires_at, created_at, updated_at, deleted_at + FROM user_local_api_keys + WHERE user_uuid = $1 AND is_active = TRUE AND deleted_at IS NULL + ORDER BY id DESC + LIMIT 1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await +} + +pub async fn fetch_api_key_by_hash( + pool: &Pool, + key_hash: &str, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiKeyDto>( + r#" + SELECT + id, uuid, user_uuid, key_prefix, key_hash, api_key, is_active, requests_today, + daily_limit, last_reset_date, last_used_at, expires_at, created_at, updated_at, deleted_at + FROM user_local_api_keys + WHERE key_hash = $1 AND is_active = TRUE AND deleted_at IS NULL + LIMIT 1 + "#, + ) + .bind(key_hash) + .fetch_optional(pool) + .await +} + +pub async fn deactivate_api_keys_for_user(pool: &Pool, user_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_local_api_keys + SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $1 AND is_active = TRUE AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn insert_api_key( + pool: &Pool, + user_uuid: Uuid, + key_prefix: &str, + key_hash: &str, + api_key: &str, + daily_limit: i32, +) -> Result { + sqlx::query_as::<_, LocalApiKeyDto>( + r#" + INSERT INTO user_local_api_keys (user_uuid, key_prefix, key_hash, api_key, daily_limit) + VALUES ($1, $2, $3, $4, $5) + RETURNING + id, uuid, user_uuid, key_prefix, key_hash, api_key, is_active, requests_today, + daily_limit, last_reset_date, last_used_at, expires_at, created_at, updated_at, deleted_at + "#, + ) + .bind(user_uuid) + .bind(key_prefix) + .bind(key_hash) + .bind(api_key) + .bind(daily_limit) + .fetch_one(pool) + .await +} + +pub async fn fetch_api_key_permission( + pool: &Pool, + api_key_id: i32, + permission_code: &str, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiKeyPermissionDto>( + r#" + SELECT + id, uuid, api_key_id, permission_code, is_enabled, rate_limit_per_minute, + rate_limit_per_hour, created_at, updated_at, deleted_at + FROM user_local_api_key_permissions + WHERE api_key_id = $1 + AND permission_code = $2 + AND deleted_at IS NULL + LIMIT 1 + "#, + ) + .bind(api_key_id) + .bind(permission_code) + .fetch_optional(pool) + .await +} + +pub async fn insert_api_key_permission( + pool: &Pool, + api_key_id: i32, + permission_code: &str, + rate_limit_per_minute: i32, + rate_limit_per_hour: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_local_api_key_permissions ( + api_key_id, permission_code, is_enabled, rate_limit_per_minute, rate_limit_per_hour + ) + VALUES ($1, $2, TRUE, $3, $4) + ON CONFLICT (api_key_id, permission_code) DO NOTHING + "#, + ) + .bind(api_key_id) + .bind(permission_code) + .bind(rate_limit_per_minute) + .bind(rate_limit_per_hour) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn fetch_permission_definition( + pool: &Pool, + permission_code: &str, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiPermissionDefinitionDto>( + r#" + SELECT + id, uuid, permission_code, name, description, default_enabled, + default_rate_limit_per_minute, default_rate_limit_per_hour, sort_order, + created_at, updated_at, deleted_at + FROM local_api_permission_definitions + WHERE permission_code = $1 AND deleted_at IS NULL + LIMIT 1 + "#, + ) + .bind(permission_code) + .fetch_optional(pool) + .await +} + +pub async fn fetch_permission_definitions( + pool: &Pool, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiPermissionDefinitionDto>( + r#" + SELECT + id, uuid, permission_code, name, description, default_enabled, + default_rate_limit_per_minute, default_rate_limit_per_hour, sort_order, + created_at, updated_at, deleted_at + FROM local_api_permission_definitions + WHERE deleted_at IS NULL + ORDER BY sort_order ASC, id ASC + "#, + ) + .fetch_all(pool) + .await +} + +pub async fn reset_api_key_daily_usage( + pool: &Pool, + api_key_id: i32, + today: chrono::NaiveDate, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_local_api_keys + SET requests_today = 0, last_reset_date = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + "#, + ) + .bind(api_key_id) + .bind(today) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn increment_api_key_usage( + pool: &Pool, + api_key_id: i32, + used_at: DateTime, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_local_api_keys + SET requests_today = requests_today + 1, last_used_at = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + "#, + ) + .bind(api_key_id) + .bind(used_at) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn fetch_request_count( + pool: &Pool, + api_key_id: i32, + permission_code: &str, + window_type: &str, + window_start: DateTime, +) -> Result { + let count = sqlx::query_scalar::<_, i32>( + r#" + SELECT request_count + FROM user_local_api_request_counters + WHERE api_key_id = $1 + AND permission_code = $2 + AND window_type = $3 + AND window_start = $4 + LIMIT 1 + "#, + ) + .bind(api_key_id) + .bind(permission_code) + .bind(window_type) + .bind(window_start) + .fetch_optional(pool) + .await?; + + Ok(count.unwrap_or(0)) +} + +pub async fn increment_request_counter( + pool: &Pool, + api_key_id: i32, + permission_code: &str, + window_type: &str, + window_start: DateTime, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_local_api_request_counters ( + api_key_id, permission_code, window_type, window_start, request_count, updated_at + ) + VALUES ($1, $2, $3, $4, 1, CURRENT_TIMESTAMP) + ON CONFLICT (api_key_id, permission_code, window_type, window_start) DO UPDATE SET + request_count = user_local_api_request_counters.request_count + 1, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(api_key_id) + .bind(permission_code) + .bind(window_type) + .bind(window_start) + .execute(pool) + .await?; + + Ok(()) +} + +pub fn build_cors_origins_value(origins: &[String]) -> Value { + json!(origins) +} + +pub fn parse_cors_origins(value: &Value) -> Vec { + value + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(ToOwned::to_owned)) + .collect() + }) + .unwrap_or_default() +} + +pub fn hash_api_key(api_key: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(api_key.as_bytes()); + hex::encode(hasher.finalize()) +} diff --git a/server/src/models/maintenance.rs b/server/src/models/maintenance.rs new file mode 100644 index 00000000..e4ed9306 --- /dev/null +++ b/server/src/models/maintenance.rs @@ -0,0 +1,180 @@ +use chrono::Utc; +use crate::database::{Db as Postgres, Pool}; + +use crate::{dto::maintenance::Maintenance, entitys::maintenance::CreateMaintenanceRequest}; + +pub async fn create_maintenance( + pool: &Pool, + request: CreateMaintenanceRequest, +) -> Result { + let now = Utc::now(); + + // 开始事务 + let mut tx = pool.begin().await?; + + // 关闭所有其他活跃的维护 + sqlx::query( + r#" + UPDATE maintenances + SET status = 'inactive', updated_at = $1 + WHERE status = 'active' + "#, + ) + .bind(now) + .execute(&mut *tx) + .await?; + + // 创建新的维护记录 + let maintenance = sqlx::query_as::<_, Maintenance>( + r#" + INSERT INTO maintenances (name, description, status, start_time, end_time, maintenance_type, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, name, description, status, start_time, end_time, maintenance_type, created_at, updated_at + "# + ) + .bind(request.name) + .bind(request.description) + .bind(request.status) + .bind(request.start_time) + .bind(request.end_time) + .bind(String::from(request.maintenance_type)) + .bind(now) + .bind(now) + .fetch_one(&mut *tx) + .await?; + + // 提交事务 + tx.commit().await?; + + Ok(maintenance) +} + +pub async fn list_maintenances( + pool: &Pool, + limit: Option, + offset: Option, +) -> Result, sqlx::Error> { + let limit = limit.unwrap_or(50); + let offset = offset.unwrap_or(0); + + let maintenances = sqlx::query_as::<_, Maintenance>( + r#" + SELECT id, name, description, status, start_time, end_time, maintenance_type, + created_at, updated_at + FROM maintenances + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(maintenances) +} + +/// 结束维护 +pub async fn end_maintenance(pool: &Pool) -> Result { + let now = Utc::now(); + + // 关闭所有其他活跃的维护 + sqlx::query( + r#" + UPDATE maintenances + SET status = 'inactive', updated_at = $1 + WHERE status = 'active' + "#, + ) + .bind(now) + .execute(pool) + .await?; + + Ok(true) +} + +pub async fn update_maintenance_status( + pool: &Pool, + id: i64, + status: &str, +) -> Result { + let now = Utc::now(); + let result = sqlx::query( + r#" + UPDATE maintenances + SET status = $1, updated_at = $2 + WHERE id = $3 + "#, + ) + .bind(status) + .bind(now) + .bind(id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +pub async fn get_maintenance_by_id( + pool: &Pool, + id: i64, +) -> Result, anyhow::Error> { + let maintenance = sqlx::query_as::<_, Maintenance>( + r#" + SELECT id, name, description, status, start_time, end_time, maintenance_type, + created_at, updated_at + FROM maintenances + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await?; + + Ok(maintenance) +} + +pub async fn get_active_maintenances( + pool: &Pool, +) -> Result, anyhow::Error> { + let maintenance = sqlx::query_as::<_, Maintenance>( + r#" + SELECT id, name, description, status, start_time, end_time, maintenance_type, + created_at, updated_at + FROM maintenances + WHERE status = 'active' AND start_time <= NOW() AND end_time >= NOW() + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .fetch_optional(pool) + .await?; + + Ok(maintenance) +} + +pub async fn create_maintenances_table(pool: &Pool) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS maintenances ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + status VARCHAR(20) NOT NULL, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + maintenance_type VARCHAR(20) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_maintenances_status ON maintenances(status); + CREATE INDEX IF NOT EXISTS idx_maintenances_time ON maintenances(start_time, end_time); + CREATE INDEX IF NOT EXISTS idx_maintenances_type ON maintenances(maintenance_type); + "#, + ) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/server/src/models/messages.rs b/server/src/models/messages.rs new file mode 100644 index 00000000..747a5193 --- /dev/null +++ b/server/src/models/messages.rs @@ -0,0 +1,413 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{MessageDto, UserMessageDto}; + +// ============ Messages ============ + +/// 创建消息 +pub async fn create_message( + pool: &Pool, + sender_uuid: Option, + message_type: &str, + title: &str, + content: Option<&str>, + recipient_type: &str, + related_type: Option<&str>, + related_uuid: Option, + priority: &str, + metadata: Option, +) -> Result { + let message_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO messages (message_type, title, content, sender_uuid, recipient_type, related_type, related_uuid, priority, metadata, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active') + RETURNING uuid; + "#, + ) + .bind(message_type) + .bind(title) + .bind(content) + .bind(sender_uuid) + .bind(recipient_type) + .bind(related_type) + .bind(related_uuid) + .bind(priority) + .bind(metadata) + .fetch_one(pool) + .await?; + + Ok(message_uuid) +} + +/// 添加消息接收者 +pub async fn add_message_recipient( + pool: &Pool, + message_uuid: Uuid, + user_uuid: Uuid, + action_status: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) + VALUES ($1, $2, FALSE, $3) + ON CONFLICT (message_uuid, user_uuid) DO NOTHING; + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .bind(action_status) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量添加消息接收者 +pub async fn add_message_recipients( + pool: &Pool, + message_uuid: Uuid, + user_uuids: &[Uuid], + action_status: Option<&str>, +) -> Result<(), Error> { + let mut tx = pool.begin().await?; + + for user_uuid in user_uuids { + sqlx::query( + r#" + INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) + VALUES ($1, $2, FALSE, $3) + ON CONFLICT (message_uuid, user_uuid) DO NOTHING; + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .bind(action_status) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + Ok(()) +} + +/// 查询用户消息列表 +pub async fn fetch_user_messages( + pool: &Pool, + user_uuid: Uuid, + offset: i64, + limit: i64, + message_type: Option<&str>, + is_read: Option, + action_status: Option<&str>, + priority: Option<&str>, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT + m.uuid AS message_uuid, + m.message_type, + m.title, + m.content, + m.sender_uuid, + m.related_type, + m.related_uuid, + m.metadata, + m.priority, + m.created_at AS message_created_at, + um.is_read, + um.read_at, + um.action_status, + um.action_at, + ui.nickname AS sender_name, + ui.email AS sender_email + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + LEFT JOIN user_infos ui ON m.sender_uuid = ui.user_uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + "#, + ); + + let mut param_count = 1; + + if message_type.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.message_type = ${}", param_count)); + } + + if is_read.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.is_read = ${}", param_count)); + } + + if action_status.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.action_status = ${}", param_count)); + } + + if priority.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.priority = ${}", param_count)); + } + + query.push_str(" ORDER BY m.created_at DESC"); + param_count += 1; + query.push_str(&format!(" LIMIT ${}", param_count)); + param_count += 1; + query.push_str(&format!(" OFFSET ${}", param_count)); + + // 构建查询参数 + let mut query_builder = sqlx::query_as::<_, UserMessageDto>(&query); + query_builder = query_builder.bind(user_uuid); + + if let Some(mt) = message_type { + query_builder = query_builder.bind(mt); + } + if let Some(ir) = is_read { + query_builder = query_builder.bind(ir); + } + if let Some(as_) = action_status { + query_builder = query_builder.bind(as_); + } + if let Some(p) = priority { + query_builder = query_builder.bind(p); + } + + query_builder = query_builder.bind(limit); + query_builder = query_builder.bind(offset); + + let recs = query_builder.fetch_all(pool).await?; + + Ok(recs) +} + +/// 查询用户消息总数 +pub async fn fetch_user_messages_count( + pool: &Pool, + user_uuid: Uuid, + message_type: Option<&str>, + is_read: Option, + action_status: Option<&str>, + priority: Option<&str>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(*) + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + "#, + ); + + let mut param_count = 1; + + if message_type.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.message_type = ${}", param_count)); + } + + if is_read.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.is_read = ${}", param_count)); + } + + if action_status.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.action_status = ${}", param_count)); + } + + if priority.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.priority = ${}", param_count)); + } + + let mut query_builder = sqlx::query_scalar::<_, i64>(&query); + query_builder = query_builder.bind(user_uuid); + + if let Some(mt) = message_type { + query_builder = query_builder.bind(mt); + } + if let Some(ir) = is_read { + query_builder = query_builder.bind(ir); + } + if let Some(as_) = action_status { + query_builder = query_builder.bind(as_); + } + if let Some(p) = priority { + query_builder = query_builder.bind(p); + } + + let count = query_builder.fetch_one(pool).await?; + + Ok(count) +} + +/// 标记消息为已读 +pub async fn mark_message_read( + pool: &Pool, + message_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_messages + SET is_read = TRUE, read_at = NOW(), updated_at = NOW() + WHERE message_uuid = $1 AND user_uuid = $2 AND is_read = FALSE + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量标记消息为已读 +pub async fn batch_mark_messages_read( + pool: &Pool, + message_uuids: &[Uuid], + user_uuid: Uuid, +) -> Result<(), Error> { + let mut tx = pool.begin().await?; + + for message_uuid in message_uuids { + sqlx::query( + r#" + UPDATE user_messages + SET is_read = TRUE, read_at = NOW(), updated_at = NOW() + WHERE message_uuid = $1 AND user_uuid = $2 AND is_read = FALSE + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + Ok(()) +} + +/// 处理消息(接受/拒绝) +pub async fn handle_message( + pool: &Pool, + message_uuid: Uuid, + user_uuid: Uuid, + action: &str, +) -> Result<(), Error> { + let action_status = match action { + "accept" => "accepted", + "reject" => "rejected", + _ => return Err(Error::RowNotFound), + }; + + sqlx::query( + r#" + UPDATE user_messages + SET action_status = $1, action_at = NOW(), updated_at = NOW() + WHERE message_uuid = $2 AND user_uuid = $3 + "#, + ) + .bind(action_status) + .bind(message_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 获取用户消息统计 +pub async fn fetch_user_message_stats( + pool: &Pool, + user_uuid: Uuid, +) -> Result<(i64, i64, std::collections::HashMap), Error> { + // 总消息数 + let total: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + // 未读消息数 + let unread: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND um.is_read = FALSE AND m.deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + // 按类型统计 + let type_stats: Vec<(String, i64)> = sqlx::query_as( + r#" + SELECT m.message_type, COUNT(*) as count + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + GROUP BY m.message_type + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + let mut by_type = std::collections::HashMap::new(); + for (msg_type, count) in type_stats { + by_type.insert(msg_type, count); + } + + Ok((total, unread, by_type)) +} + +/// 删除消息(软删除) +pub async fn delete_message(pool: &Pool, message_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE messages + SET deleted_at = NOW(), status = 'deleted', updated_at = NOW() + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(message_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 根据 UUID 查询消息 +pub async fn fetch_message_by_uuid( + pool: &Pool, + message_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, MessageDto>( + r#" + SELECT id, uuid, message_type, title, content, sender_uuid, recipient_type, + related_type, related_uuid, metadata, status, priority, + created_at, updated_at, deleted_at + FROM messages + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(message_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} diff --git a/server/src/models/mod.rs b/server/src/models/mod.rs new file mode 100644 index 00000000..117ddd43 --- /dev/null +++ b/server/src/models/mod.rs @@ -0,0 +1,43 @@ +pub mod maintenance; +pub mod strategy_types; +pub mod user; +pub mod version_types; +pub mod versions; + +// 新增模块 +pub mod accounts; +pub mod audit; +pub mod billing; +pub mod environments; +pub mod extensions; +pub mod group_member_permissions; +pub mod local_api; +pub mod messages; +pub mod preferences; +pub mod proxies; +pub mod proxy_visible_teams; +pub mod referral; +pub mod rpa; +pub mod teams; +pub mod workspace_quotas; +pub mod workspaces; + +pub use maintenance::*; +pub use strategy_types::*; +pub use user::*; +pub use version_types::*; +pub use versions::*; + +// 新增导出 +pub use accounts::*; +pub use audit::*; +pub use billing::*; +pub use environments::*; +pub use group_member_permissions::*; +pub use local_api::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visible_teams::*; +pub use teams::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/server/src/models/preferences.rs b/server/src/models/preferences.rs new file mode 100644 index 00000000..e1e9541a --- /dev/null +++ b/server/src/models/preferences.rs @@ -0,0 +1,54 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::UserPreferenceDto; + +/// 获取用户偏好设置 +pub async fn fetch_user_preferences( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserPreferenceDto>( + r#" + SELECT id, user_uuid, theme, language, notifications_enabled, created_at, updated_at + FROM user_preferences + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 创建或更新用户偏好设置 +pub async fn upsert_user_preferences( + pool: &Pool, + user_uuid: Uuid, + theme: Option<&str>, + language: Option<&str>, + notifications_enabled: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_preferences (user_uuid, theme, language, notifications_enabled) + VALUES ($1, COALESCE($2, 'system'), COALESCE($3, 'zh-CN'), COALESCE($4, true)) + ON CONFLICT (user_uuid) DO UPDATE SET + theme = COALESCE($2, user_preferences.theme), + language = COALESCE($3, user_preferences.language), + notifications_enabled = COALESCE($4, user_preferences.notifications_enabled), + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(theme) + .bind(language) + .bind(notifications_enabled) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/server/src/models/proxies.rs b/server/src/models/proxies.rs new file mode 100644 index 00000000..c6561aa4 --- /dev/null +++ b/server/src/models/proxies.rs @@ -0,0 +1,304 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{ProxyDto, ProxyHealthCheckDto}; + +/// 创建代理 +pub async fn insert_proxy( + pool: &Pool, + workspace_uuid: Uuid, + owner_uuid: Uuid, + name: &str, + host: &str, + port: i32, + proxy_type: &str, + username: Option<&str>, + password: Option<&str>, + country: Option<&str>, + city: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO proxies (workspace_uuid, owner_uuid, name, host, port, proxy_type, + username, password, country, city) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(owner_uuid) + .bind(name) + .bind(host) + .bind(port) + .bind(proxy_type) + .bind(username) + .bind(password) + .bind(country) + .bind(city) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询代理列表 +pub async fn fetch_proxies( + pool: &Pool, + workspace_uuid: Uuid, + proxy_type: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.workspace_uuid = $1 + AND ($2::varchar IS NULL OR p.proxy_type = $2) + AND ($3::varchar IS NULL OR p.status = $3) + AND p.deleted_at IS NULL + ORDER BY p.created_at DESC + LIMIT $5 OFFSET $6 + "#, + ) + .bind(workspace_uuid) + .bind(proxy_type) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询代理总数 +pub async fn fetch_proxies_count( + pool: &Pool, + workspace_uuid: Uuid, + proxy_type: Option<&str>, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM proxies + WHERE workspace_uuid = $1 + AND ($2::varchar IS NULL OR proxy_type = $2) + AND ($3::varchar IS NULL OR status = $3) + AND deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .bind(proxy_type) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询代理 +pub async fn fetch_proxy_by_uuid( + pool: &Pool, + proxy_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.uuid = $1 AND p.deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新代理 +pub async fn update_proxy( + pool: &Pool, + proxy_uuid: Uuid, + name: Option<&str>, + host: Option<&str>, + port: Option, + proxy_type: Option<&str>, + username: Option<&str>, + password: Option<&str>, + country: Option<&str>, + city: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies + SET name = COALESCE($1, name), + host = COALESCE($2, host), + port = COALESCE($3, port), + proxy_type = COALESCE($4, proxy_type), + username = COALESCE($5, username), + password = COALESCE($6, password), + country = COALESCE($7, country), + city = COALESCE($8, city) + WHERE uuid = $9 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(host) + .bind(port) + .bind(proxy_type) + .bind(username) + .bind(password) + .bind(country) + .bind(city) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新代理检测结果 +pub async fn update_proxy_check_result( + pool: &Pool, + proxy_uuid: Uuid, + status: &str, + latency: Option, + ip_address: Option<&str>, + country: Option<&str>, + city: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies + SET status = $1, + latency = $2, + last_check_ip = $3, + country = $4, + city = $5, + last_checked_at = CURRENT_TIMESTAMP + WHERE uuid = $6 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(latency) + .bind(ip_address) + .bind(country) + .bind(city) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加代理使用次数 +pub async fn increment_proxy_usage(pool: &Pool, proxy_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies SET usage_count = usage_count + 1 + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除代理 +pub async fn delete_proxy(pool: &Pool, proxy_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除代理 +pub async fn batch_delete_proxies( + pool: &Pool, + proxy_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE proxies SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Proxy Health Checks ============ + +/// 记录代理健康检查 +pub async fn insert_proxy_health_check( + pool: &Pool, + proxy_uuid: Uuid, + status: &str, + latency: Option, + ip_address: Option<&str>, + error_message: Option<&str>, +) -> Result { + let id: i64 = sqlx::query_scalar( + r#" + INSERT INTO proxy_health_checks (proxy_uuid, status, latency, ip_address, error_message) + VALUES ($1, $2, $3, $4, $5) + RETURNING id; + "#, + ) + .bind(proxy_uuid) + .bind(status) + .bind(latency) + .bind(ip_address) + .bind(error_message) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询代理健康检查历史 +pub async fn fetch_proxy_health_checks( + pool: &Pool, + proxy_uuid: Uuid, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyHealthCheckDto>( + r#" + SELECT id, proxy_uuid, status, latency, ip_address, error_message, checked_at + FROM proxy_health_checks + WHERE proxy_uuid = $1 + ORDER BY checked_at DESC + LIMIT $2 + "#, + ) + .bind(proxy_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/server/src/models/proxy_visible_teams.rs b/server/src/models/proxy_visible_teams.rs new file mode 100644 index 00000000..06966843 --- /dev/null +++ b/server/src/models/proxy_visible_teams.rs @@ -0,0 +1,288 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{ProxyDto, ProxyVisibleTeamDto}; + +/// 添加代理可见团队 +pub async fn insert_proxy_visible_team( + pool: &Pool, + proxy_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO proxy_visible_teams (proxy_uuid, workspace_uuid, team_uuid) + VALUES ($1, $2, $3) + ON CONFLICT (proxy_uuid, team_uuid) DO NOTHING + "#, + ) + .bind(proxy_uuid) + .bind(workspace_uuid) + .bind(team_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除代理可见团队 +pub async fn remove_proxy_visible_team( + pool: &Pool, + proxy_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM proxy_visible_teams + WHERE proxy_uuid = $1 AND team_uuid = $2 + "#, + ) + .bind(proxy_uuid) + .bind(team_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询代理的可见团队列表 +pub async fn fetch_visible_teams_by_proxy( + pool: &Pool, + proxy_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyVisibleTeamDto>( + r#" + SELECT proxy_uuid, workspace_uuid, team_uuid, created_at + FROM proxy_visible_teams + WHERE proxy_uuid = $1 + ORDER BY created_at + "#, + ) + .bind(proxy_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询团队可见的代理列表 +pub async fn fetch_visible_proxies_by_team( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + INNER JOIN proxy_visible_teams pvt ON p.uuid = pvt.proxy_uuid + WHERE pvt.workspace_uuid = $1 AND pvt.team_uuid = $2 + AND p.deleted_at IS NULL + ORDER BY p.created_at DESC + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 检查代理对团队是否可见 +pub async fn check_proxy_visibility( + pool: &Pool, + proxy_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM proxy_visible_teams + WHERE proxy_uuid = $1 AND workspace_uuid = $2 AND team_uuid = $3 + "#, + ) + .bind(proxy_uuid) + .bind(workspace_uuid) + .bind(team_uuid) + .fetch_one(pool) + .await?; + + Ok(count > 0) +} + +/// 查询工作空间所有可见的代理(包括工作空间 Owner 和代理所有者的代理) +pub async fn fetch_visible_proxies_for_user( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Option, +) -> Result, Error> { + // 工作空间 Owner 可以看到所有代理 + // 代理所有者可以看到自己的代理 + // 团队成员可以看到 proxy_visible_teams 中包含其团队的代理 + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT DISTINCT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.workspace_uuid = $1 + AND p.deleted_at IS NULL + AND ( + -- 工作空间 Owner + EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.uuid = $1 AND w.owner_uuid = $2 AND w.deleted_at IS NULL + ) + -- 代理所有者 + OR p.owner_uuid = $2 + -- 团队成员可见的代理 + OR ( + $3 IS NOT NULL + AND EXISTS ( + SELECT 1 FROM proxy_visible_teams pvt + WHERE pvt.proxy_uuid = p.uuid + AND pvt.workspace_uuid = $1 + AND pvt.team_uuid = $3 + ) + ) + ) + ORDER BY p.created_at DESC + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 分页查询用户可见的代理列表,并支持名称搜索和筛选 +pub async fn fetch_visible_proxies_for_user_paginated( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Option, + name_keyword: Option<&str>, + proxy_type: Option<&str>, + status: Option<&str>, + country: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let name_keyword = name_keyword.map(|keyword| format!("%{}%", keyword.trim())); + + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT DISTINCT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.workspace_uuid = $1 + AND p.deleted_at IS NULL + AND ($4::text IS NULL OR p.name ILIKE $4) + AND ($5::text IS NULL OR p.proxy_type = $5) + AND ($6::text IS NULL OR p.status = $6) + AND ($7::text IS NULL OR p.country = $7) + AND ( + EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.uuid = $1 AND w.owner_uuid = $2 AND w.deleted_at IS NULL + ) + OR p.owner_uuid = $2 + OR ( + $3 IS NOT NULL + AND EXISTS ( + SELECT 1 FROM proxy_visible_teams pvt + WHERE pvt.proxy_uuid = p.uuid + AND pvt.workspace_uuid = $1 + AND pvt.team_uuid = $3 + ) + ) + ) + ORDER BY p.created_at DESC + OFFSET $8 LIMIT $9 + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .bind(name_keyword) + .bind(proxy_type) + .bind(status) + .bind(country) + .bind(offset) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询用户可见代理总数,并支持名称搜索和筛选 +pub async fn fetch_visible_proxies_for_user_count( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Option, + name_keyword: Option<&str>, + proxy_type: Option<&str>, + status: Option<&str>, + country: Option<&str>, +) -> Result { + let name_keyword = name_keyword.map(|keyword| format!("%{}%", keyword.trim())); + + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(DISTINCT p.uuid) + FROM proxies p + WHERE p.workspace_uuid = $1 + AND p.deleted_at IS NULL + AND ($4::text IS NULL OR p.name ILIKE $4) + AND ($5::text IS NULL OR p.proxy_type = $5) + AND ($6::text IS NULL OR p.status = $6) + AND ($7::text IS NULL OR p.country = $7) + AND ( + EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.uuid = $1 AND w.owner_uuid = $2 AND w.deleted_at IS NULL + ) + OR p.owner_uuid = $2 + OR ( + $3 IS NOT NULL + AND EXISTS ( + SELECT 1 FROM proxy_visible_teams pvt + WHERE pvt.proxy_uuid = p.uuid + AND pvt.workspace_uuid = $1 + AND pvt.team_uuid = $3 + ) + ) + ) + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .bind(name_keyword) + .bind(proxy_type) + .bind(status) + .bind(country) + .fetch_one(pool) + .await?; + + Ok(count) +} diff --git a/server/src/models/referral.rs b/server/src/models/referral.rs new file mode 100644 index 00000000..f6827de1 --- /dev/null +++ b/server/src/models/referral.rs @@ -0,0 +1,569 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{ + RedeemOptionDto, RedeemRecordDto, ReferralLinkDto, ReferralLinkTierDto, ReferralRewardDto, + UserReferralPointsDto, +}; +use crate::dto::ReferredUserRow; + +// ============ Referral Link Tiers ============ + +/// 查询所有层级 +pub async fn fetch_referral_tiers( + pool: &Pool, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ReferralLinkTierDto>( + r#" + SELECT id, uuid, name, unlock_threshold, reward_rate, discount_rate, + description, sort_order, created_at, updated_at + FROM referral_link_tiers + ORDER BY unlock_threshold ASC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据用户当前已解锁的层级,更新其推广链接的解锁状态。 +/// +/// 约定: +/// - 同一用户的所有推广链接默认创建完成(最多 4 条),分别对应不同 tier_uuid; +/// - 这里根据传入的 unlocked_tier_uuids,将属于这些层级的链接标记为 unlocked = TRUE, +/// 其他链接统一标记为 FALSE。 +pub async fn update_referral_links_unlock_status_for_user( + pool: &Pool, + user_uuid: Uuid, + unlocked_tier_uuids: &[Uuid], +) -> Result<(), Error> { + // 先将该用户所有链接标记为未解锁 + sqlx::query( + r#" + UPDATE referral_links + SET unlocked = FALSE + WHERE user_uuid = $1; + "#, + ) + .bind(user_uuid) + .execute(pool) + .await?; + + // 再根据传入的 tier_uuid 集合解锁对应链接 + if !unlocked_tier_uuids.is_empty() { + sqlx::query( + r#" + UPDATE referral_links + SET unlocked = TRUE + WHERE user_uuid = $1 + AND tier_uuid = ANY($2::uuid[]); + "#, + ) + .bind(user_uuid) + .bind(unlocked_tier_uuids) + .execute(pool) + .await?; + } + + Ok(()) +} + +/// 根据解锁阈值查询层级 +pub async fn fetch_tier_by_threshold( + pool: &Pool, + threshold: i32, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ReferralLinkTierDto>( + r#" + SELECT id, uuid, name, unlock_threshold, reward_rate, discount_rate, + description, sort_order, created_at, updated_at + FROM referral_link_tiers + WHERE unlock_threshold <= $1 + ORDER BY unlock_threshold DESC + LIMIT 1 + "#, + ) + .bind(threshold) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询下一个层级 +pub async fn fetch_next_tier( + pool: &Pool, + current_threshold: i32, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ReferralLinkTierDto>( + r#" + SELECT id, uuid, name, unlock_threshold, reward_rate, discount_rate, + description, sort_order, created_at, updated_at + FROM referral_link_tiers + WHERE unlock_threshold > $1 + ORDER BY unlock_threshold ASC + LIMIT 1 + "#, + ) + .bind(current_threshold) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +// ============ Referral Links ============ + +/// 查询用户的推广链接列表 +pub async fn fetch_user_referral_links( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + // 为了保证前端展示顺序与层级解锁顺序一致,这里按对应层级的 unlock_threshold 排序, + // 对于没有 tier_uuid 的链接,则退而按创建时间排序并排在最后。 + let recs = sqlx::query_as::<_, ReferralLinkDto>( + r#" + SELECT rl.id, + rl.uuid, + rl.user_uuid, + rl.code, + rl.url, + rl.tier_uuid, + rl.unlocked, + rl.is_current, + rl.reward_rate, + rl.discount_rate, + rl.registered_users, + rl.paid_users, + rl.total_consumption, + rl.last_30_days_consumption, + rl.created_at, + rl.updated_at + FROM referral_links rl + LEFT JOIN referral_link_tiers t + ON rl.tier_uuid = t.uuid + WHERE rl.user_uuid = $1 + ORDER BY + t.unlock_threshold ASC NULLS LAST, + rl.created_at ASC + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询当前推广链接 +pub async fn fetch_current_referral_link( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ReferralLinkDto>( + r#" + SELECT id, uuid, user_uuid, code, url, tier_uuid, unlocked, is_current, + reward_rate, discount_rate, registered_users, paid_users, + total_consumption, last_30_days_consumption, created_at, updated_at + FROM referral_links + WHERE user_uuid = $1 AND is_current = true + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 切换当前推广链接 +pub async fn switch_current_referral_link( + pool: &Pool, + user_uuid: Uuid, + link_uuid: Uuid, +) -> Result<(), Error> { + // 先取消所有当前链接 + sqlx::query("UPDATE referral_links SET is_current = false WHERE user_uuid = $1") + .bind(user_uuid) + .execute(pool) + .await?; + + // 设置新的当前链接 + sqlx::query("UPDATE referral_links SET is_current = true WHERE uuid = $1 AND user_uuid = $2") + .bind(link_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ User Referrals ============ + +/// 查询被邀请用户列表 +pub async fn fetch_referred_users( + pool: &Pool, + inviter_uuid: Uuid, + keyword: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ReferredUserRow>( + r#" + SELECT + ur.id, + ui.email, + ur.status, + ur.link_uuid, + ur.total_consumption, + ur.last_30_days_consumption, + ur.registered_at + FROM user_referrals ur + JOIN user_infos ui ON ui.user_uuid = ur.invitee_uuid + WHERE ur.inviter_uuid = $1 + AND ($2::text IS NULL OR ui.email ILIKE '%' || $2 || '%') + AND ($3::varchar IS NULL OR ur.status = $3) + ORDER BY registered_at DESC + LIMIT $4 OFFSET $5 + "#, + ) + .bind(inviter_uuid) + .bind(keyword) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询被邀请用户总数 +pub async fn fetch_referred_users_count( + pool: &Pool, + inviter_uuid: Uuid, + keyword: Option<&str>, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM user_referrals ur + JOIN user_infos ui ON ui.user_uuid = ur.invitee_uuid + WHERE ur.inviter_uuid = $1 + AND ($2::text IS NULL OR ui.email ILIKE '%' || $2 || '%') + AND ($3::varchar IS NULL OR ur.status = $3) + "#, + ) + .bind(inviter_uuid) + .bind(keyword) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 获取推广统计 +pub async fn fetch_referral_stats( + pool: &Pool, + user_uuid: Uuid, +) -> Result<(i32, i32, Decimal, Decimal), Error> { + let stats: (i64, i64, Option, Option) = sqlx::query_as( + r#" + SELECT + COUNT(*) as total_referrals, + COUNT(CASE WHEN status = 'paid' THEN 1 END) as paid_referrals, + COALESCE(SUM(total_consumption), 0) as total_consumption, + COALESCE(SUM(last_30_days_consumption), 0) as last_30_days_consumption + FROM user_referrals + WHERE inviter_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + Ok(( + stats.0 as i32, + stats.1 as i32, + stats.2.unwrap_or(Decimal::ZERO), + stats.3.unwrap_or(Decimal::ZERO), + )) +} + +// ============ Referral Rewards ============ + +/// 查询奖励记录 +pub async fn fetch_referral_rewards( + pool: &Pool, + user_uuid: Uuid, + keyword: Option<&str>, + reward_type: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ReferralRewardDto>( + r#" + SELECT id, uuid, user_uuid, reward_type, points, description, + referred_user_uuid, link_uuid, status, created_at + FROM referral_rewards + WHERE user_uuid = $1 + AND ($2::text IS NULL OR COALESCE(description, '') ILIKE '%' || $2 || '%') + AND ($3::varchar IS NULL OR reward_type = $3) + AND ($4::varchar IS NULL OR status = $4) + ORDER BY created_at DESC + LIMIT $5 OFFSET $6 + "#, + ) + .bind(user_uuid) + .bind(keyword) + .bind(reward_type) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询奖励记录总数 +pub async fn fetch_referral_rewards_count( + pool: &Pool, + user_uuid: Uuid, + keyword: Option<&str>, + reward_type: Option<&str>, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM referral_rewards + WHERE user_uuid = $1 + AND ($2::text IS NULL OR COALESCE(description, '') ILIKE '%' || $2 || '%') + AND ($3::varchar IS NULL OR reward_type = $3) + AND ($4::varchar IS NULL OR status = $4) + "#, + ) + .bind(user_uuid) + .bind(keyword) + .bind(reward_type) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 获取总奖励积分 +pub async fn fetch_total_reward_points( + pool: &Pool, + user_uuid: Uuid, +) -> Result { + let total: i64 = sqlx::query_scalar( + r#" + SELECT COALESCE(SUM(points), 0) FROM referral_rewards + WHERE user_uuid = $1 AND status = 'completed' + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + Ok(total as i32) +} + +// ============ User Referral Points ============ + +/// 获取用户积分 +pub async fn fetch_user_points( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserReferralPointsDto>( + r#" + SELECT id, user_uuid, total_points, available_points, used_points, + pending_points, created_at, updated_at + FROM user_referral_points + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 创建或更新用户积分 +pub async fn upsert_user_points( + pool: &Pool, + user_uuid: Uuid, + total_points: i32, + available_points: i32, + used_points: i32, + pending_points: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_referral_points (user_uuid, total_points, available_points, used_points, pending_points) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_uuid) DO UPDATE SET + total_points = $2, + available_points = $3, + used_points = $4, + pending_points = $5, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(total_points) + .bind(available_points) + .bind(used_points) + .bind(pending_points) + .execute(pool) + .await?; + + Ok(()) +} + +/// 扣减积分 +pub async fn deduct_user_points( + pool: &Pool, + user_uuid: Uuid, + points: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_referral_points + SET available_points = available_points - $1, + used_points = used_points + $1, + updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $2 AND available_points >= $1 + "#, + ) + .bind(points) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Redeem Options ============ + +/// 查询兑换选项 +pub async fn fetch_redeem_options(pool: &Pool) -> Result, Error> { + let recs = sqlx::query_as::<_, RedeemOptionDto>( + r#" + SELECT id, uuid, redeem_type, name, description, points_required, + value, currency, exchange_rate, status, sort_order, created_at, updated_at + FROM redeem_options + WHERE status = 'active' + ORDER BY sort_order ASC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据 UUID 查询兑换选项 +pub async fn fetch_redeem_option_by_uuid( + pool: &Pool, + option_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, RedeemOptionDto>( + r#" + SELECT id, uuid, redeem_type, name, description, points_required, + value, currency, exchange_rate, status, sort_order, created_at, updated_at + FROM redeem_options + WHERE uuid = $1 + "#, + ) + .bind(option_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +// ============ Redeem Records ============ + +/// 创建兑换记录 +pub async fn insert_redeem_record( + pool: &Pool, + user_uuid: Uuid, + option_uuid: Uuid, + points_used: i32, + value: Decimal, + currency: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO redeem_records (user_uuid, option_uuid, points_used, value, currency, status) + VALUES ($1, $2, $3, $4, $5, 'completed') + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(option_uuid) + .bind(points_used) + .bind(value) + .bind(currency) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询兑换记录 +pub async fn fetch_redeem_records( + pool: &Pool, + user_uuid: Uuid, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RedeemRecordDto>( + r#" + SELECT id, uuid, user_uuid, option_uuid, points_used, value, + currency, status, created_at, completed_at + FROM redeem_records + WHERE user_uuid = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(user_uuid) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询兑换记录总数 +pub async fn fetch_redeem_records_count( + pool: &Pool, + user_uuid: Uuid, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM redeem_records WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + Ok(count) +} diff --git a/server/src/models/rpa.rs b/server/src/models/rpa.rs new file mode 100644 index 00000000..e751d1d3 --- /dev/null +++ b/server/src/models/rpa.rs @@ -0,0 +1,543 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{RpaTaskDto, RpaTaskEnvironmentDto, RpaTaskRunDto, RpaTaskStepDto}; + +// ============ RPA Tasks ============ + +/// 创建 RPA 任务 +pub async fn insert_rpa_task( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + name: &str, + description: Option<&str>, + tags: Option<&serde_json::Value>, + trigger_type: &str, + schedule: Option<&str>, + cron_expression: Option<&str>, + run_mode: &str, + retry_count: Option, + retry_interval: Option, + timeout: Option, + concurrency: Option, + stop_on_error: Option, + notify_on_complete: Option, + notify_on_error: Option, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO rpa_tasks (user_uuid, team_uuid, name, description, tags, trigger_type, + schedule, cron_expression, run_mode, retry_count, retry_interval, + timeout, concurrency, stop_on_error, notify_on_complete, notify_on_error) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(tags) + .bind(trigger_type) + .bind(schedule) + .bind(cron_expression) + .bind(run_mode) + .bind(retry_count) + .bind(retry_interval) + .bind(timeout) + .bind(concurrency) + .bind(stop_on_error) + .bind(notify_on_complete) + .bind(notify_on_error) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询 RPA 任务列表 +pub async fn fetch_rpa_tasks( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + keyword: Option<&str>, + status: Option<&str>, + trigger_type: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, tags, trigger_type, + schedule, cron_expression, run_mode, retry_count, retry_interval, timeout, + concurrency, stop_on_error, notify_on_complete, notify_on_error, status, + run_count, success_count, last_run_at, next_run_at, created_at, updated_at, deleted_at + , (SELECT COUNT(*) FROM rpa_task_environments rte WHERE rte.task_uuid = rpa_tasks.uuid) AS environment_count + FROM rpa_tasks + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND ($3::varchar IS NULL OR name ILIKE $3 OR COALESCE(description, '') ILIKE $3) + AND ($4::varchar IS NULL OR status = $4) + AND ($5::varchar IS NULL OR trigger_type = $5) + AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT $6 OFFSET $7 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(keyword.map(|k| format!("%{}%", k))) + .bind(status) + .bind(trigger_type) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询 RPA 任务总数 +pub async fn fetch_rpa_tasks_count( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + keyword: Option<&str>, + status: Option<&str>, + trigger_type: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM rpa_tasks + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND ($3::varchar IS NULL OR status = $3) + AND ($4::varchar IS NULL OR trigger_type = $4) + AND ($5::varchar IS NULL OR name ILIKE $5 OR COALESCE(description, '') ILIKE $5) + AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(status) + .bind(trigger_type) + .bind(keyword.map(|k| format!("%{}%", k))) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询 RPA 任务 +pub async fn fetch_rpa_task_by_uuid( + pool: &Pool, + task_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, RpaTaskDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, tags, trigger_type, + schedule, cron_expression, run_mode, retry_count, retry_interval, timeout, + concurrency, stop_on_error, notify_on_complete, notify_on_error, status, + run_count, success_count, last_run_at, next_run_at, created_at, updated_at, deleted_at + , (SELECT COUNT(*) FROM rpa_task_environments rte WHERE rte.task_uuid = rpa_tasks.uuid) AS environment_count + FROM rpa_tasks + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(task_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新 RPA 任务 +pub async fn update_rpa_task( + pool: &Pool, + task_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + tags: Option<&serde_json::Value>, + trigger_type: Option<&str>, + schedule: Option<&str>, + cron_expression: Option<&str>, + run_mode: Option<&str>, + retry_count: Option, + retry_interval: Option, + timeout: Option, + concurrency: Option, + stop_on_error: Option, + notify_on_complete: Option, + notify_on_error: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_tasks + SET name = COALESCE($1, name), + description = COALESCE($2, description), + tags = COALESCE($3, tags), + trigger_type = COALESCE($4, trigger_type), + schedule = COALESCE($5, schedule), + cron_expression = COALESCE($6, cron_expression), + run_mode = COALESCE($7, run_mode), + retry_count = COALESCE($8, retry_count), + retry_interval = COALESCE($9, retry_interval), + timeout = COALESCE($10, timeout), + concurrency = COALESCE($11, concurrency), + stop_on_error = COALESCE($12, stop_on_error), + notify_on_complete = COALESCE($13, notify_on_complete), + notify_on_error = COALESCE($14, notify_on_error), + updated_at = CURRENT_TIMESTAMP + WHERE uuid = $15 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(tags) + .bind(trigger_type) + .bind(schedule) + .bind(cron_expression) + .bind(run_mode) + .bind(retry_count) + .bind(retry_interval) + .bind(timeout) + .bind(concurrency) + .bind(stop_on_error) + .bind(notify_on_complete) + .bind(notify_on_error) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新任务状态 +pub async fn update_rpa_task_status( + pool: &Pool, + task_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_tasks + SET status = $1, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除 RPA 任务 +pub async fn delete_rpa_task(pool: &Pool, task_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_tasks SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除 RPA 任务 +pub async fn batch_delete_rpa_tasks( + pool: &Pool, + task_uuids: &[Uuid], +) -> Result { + let result = sqlx::query( + r#" + UPDATE rpa_tasks SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = ANY($1) AND deleted_at IS NULL + "#, + ) + .bind(task_uuids) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ RPA Task Steps ============ + +/// 插入任务步骤 +pub async fn insert_rpa_task_step( + pool: &Pool, + task_uuid: Uuid, + step_type: &str, + name: &str, + config: &serde_json::Value, + enabled: Option, + position_x: Option, + position_y: Option, + sort_order: Option, + next_step_uuid: Option, + branch_config: Option<&serde_json::Value>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO rpa_task_steps (task_uuid, step_type, name, config, enabled, + position_x, position_y, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING uuid; + "#, + ) + .bind(task_uuid) + .bind(step_type) + .bind(name) + .bind(config) + .bind(enabled.unwrap_or(true)) + .bind(position_x) + .bind(position_y) + .bind(sort_order) + .bind(next_step_uuid) + .bind(branch_config) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询任务步骤列表 +pub async fn fetch_rpa_task_steps( + pool: &Pool, + task_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskStepDto>( + r#" + SELECT id, uuid, task_uuid, step_type, name, config, enabled, + position_x, position_y, sort_order, next_step_uuid, branch_config, + created_at, updated_at + FROM rpa_task_steps + WHERE task_uuid = $1 + ORDER BY sort_order ASC, id ASC + "#, + ) + .bind(task_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除任务所有步骤 +pub async fn delete_rpa_task_steps(pool: &Pool, task_uuid: Uuid) -> Result<(), Error> { + sqlx::query("DELETE FROM rpa_task_steps WHERE task_uuid = $1") + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ RPA Task Environments ============ + +/// 添加任务环境关联 +pub async fn insert_rpa_task_environment( + pool: &Pool, + task_uuid: Uuid, + environment_uuid: Uuid, + sort_order: Option, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO rpa_task_environments (task_uuid, environment_uuid, sort_order) + VALUES ($1, $2, $3) + RETURNING id; + "#, + ) + .bind(task_uuid) + .bind(environment_uuid) + .bind(sort_order) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询任务环境关联 +pub async fn fetch_rpa_task_environments( + pool: &Pool, + task_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskEnvironmentDto>( + r#" + SELECT id, task_uuid, environment_uuid, sort_order, created_at + FROM rpa_task_environments + WHERE task_uuid = $1 + ORDER BY sort_order ASC, id ASC + "#, + ) + .bind(task_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除任务所有环境关联 +pub async fn delete_rpa_task_environments( + pool: &Pool, + task_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query("DELETE FROM rpa_task_environments WHERE task_uuid = $1") + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ RPA Task Runs ============ + +/// 创建任务执行记录 +pub async fn insert_rpa_task_run( + pool: &Pool, + task_uuid: Uuid, + total_steps: i32, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO rpa_task_runs (task_uuid, status, total_steps, completed_steps, failed_steps) + VALUES ($1, 'running', $2, 0, 0) + RETURNING uuid; + "#, + ) + .bind(task_uuid) + .bind(total_steps) + .fetch_one(pool) + .await?; + + // 更新任务最后运行时间和运行次数 + sqlx::query( + r#" + UPDATE rpa_tasks + SET last_run_at = CURRENT_TIMESTAMP, + run_count = COALESCE(run_count, 0) + 1 + WHERE uuid = $1 + "#, + ) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(uuid) +} + +/// 查询任务执行记录列表 +pub async fn fetch_rpa_task_runs( + pool: &Pool, + task_uuid: Uuid, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskRunDto>( + r#" + SELECT id, uuid, task_uuid, status, total_steps, completed_steps, failed_steps, + started_at, finished_at, duration_ms, result_summary, error_message, logs + FROM rpa_task_runs + WHERE task_uuid = $1 + AND ($2::varchar IS NULL OR status = $2) + ORDER BY started_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(task_uuid) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询任务执行记录总数 +pub async fn fetch_rpa_task_runs_count( + pool: &Pool, + task_uuid: Uuid, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM rpa_task_runs + WHERE task_uuid = $1 + AND ($2::varchar IS NULL OR status = $2) + "#, + ) + .bind(task_uuid) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询执行记录 +pub async fn fetch_rpa_task_run_by_uuid( + pool: &Pool, + run_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, RpaTaskRunDto>( + r#" + SELECT id, uuid, task_uuid, status, total_steps, completed_steps, failed_steps, + started_at, finished_at, duration_ms, result_summary, error_message, logs + FROM rpa_task_runs + WHERE uuid = $1 + "#, + ) + .bind(run_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新执行记录状态 +pub async fn update_rpa_task_run_status( + pool: &Pool, + run_uuid: Uuid, + status: &str, + completed_steps: i32, + failed_steps: i32, + result_summary: Option<&str>, + error_message: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_task_runs + SET status = $1, + completed_steps = $2, + failed_steps = $3, + result_summary = $4, + error_message = $5, + finished_at = CASE WHEN $1 IN ('completed', 'failed', 'stopped') THEN CURRENT_TIMESTAMP ELSE finished_at END, + duration_ms = CASE WHEN $1 IN ('completed', 'failed', 'stopped') + THEN EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - started_at)) * 1000 + ELSE duration_ms END + WHERE uuid = $6 + "#, + ) + .bind(status) + .bind(completed_steps) + .bind(failed_steps) + .bind(result_summary) + .bind(error_message) + .bind(run_uuid) + .execute(pool) + .await?; + + Ok(()) +} + + diff --git a/server/src/models/strategy_types.rs b/server/src/models/strategy_types.rs new file mode 100644 index 00000000..c9c4cbc2 --- /dev/null +++ b/server/src/models/strategy_types.rs @@ -0,0 +1,58 @@ +use crate::dto::strategy_types::StrategyType; +use sqlx::Error; + +use crate::database::{Db as Postgres, Pool}; + +/// 根据ID查询策略类型 +pub async fn query_strategy_type_by_id( + pool: &Pool, + id: i32, +) -> Result { + let strategy_type: StrategyType = sqlx::query_as("SELECT * FROM strategy_types WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; + + Ok(strategy_type) +} + +/// 根据code查询策略类型 +pub async fn query_strategy_type_by_code( + pool: &Pool, + code: &str, +) -> Result { + let strategy_type: StrategyType = + sqlx::query_as("SELECT * FROM strategy_types WHERE code = $1") + .bind(code) + .fetch_one(pool) + .await?; + + Ok(strategy_type) +} + +/// 查询所有可用的策略类型(只查询激活的) +pub async fn query_available_strategy_types( + pool: &Pool, +) -> Result, Error> { + let strategy_types: Vec = + sqlx::query_as("SELECT * FROM strategy_types WHERE is_active = true ORDER BY code") + .fetch_all(pool) + .await?; + + Ok(strategy_types) +} + +/// 根据分类查询策略类型 +pub async fn query_strategy_types_by_category( + pool: &Pool, + category: &str, +) -> Result, Error> { + let strategy_types: Vec = sqlx::query_as( + "SELECT * FROM strategy_types WHERE category = $1 AND is_active = true ORDER BY code", + ) + .bind(category) + .fetch_all(pool) + .await?; + + Ok(strategy_types) +} diff --git a/server/src/models/teams.rs b/server/src/models/teams.rs new file mode 100644 index 00000000..fedf7a0f --- /dev/null +++ b/server/src/models/teams.rs @@ -0,0 +1,839 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{LoginHistoryDto, TeamDto, TeamInvitationDto, TeamMemberDto}; +use crate::entitys::CreateTeamRequest; + +// ============ Teams ============ + +/// 创建团队 +pub async fn insert_team( + pool: &Pool, + owner_uuid: Uuid, + payload: &CreateTeamRequest, +) -> Result { + let mut tx = pool.begin().await?; + + // 1. 创建团队 + let team_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO teams (workspace_uuid, name, description, owner_uuid) + VALUES ($1, $2, $3, $4) + RETURNING uuid; + "#, + ) + .bind(payload.workspace_uuid) + .bind(&payload.name) + .bind(&payload.description) + .bind(owner_uuid) + .fetch_one(&mut *tx) + .await?; + + // 2. 添加所有者为成员 + sqlx::query( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, status) + VALUES ($1, $2, $3, 'owner', 'active'); + "#, + ) + .bind(team_uuid) + .bind(payload.workspace_uuid) + .bind(owner_uuid) + .execute(&mut *tx) + .await?; + + // 3. 设置为用户当前团队 + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = $1 WHERE user_uuid = $2; + "#, + ) + .bind(team_uuid) + .bind(owner_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(team_uuid) +} + +/// 根据 UUID 查询团队 +pub async fn fetch_team_by_uuid( + pool: &Pool, + team_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TeamDto>( + r#" + SELECT id, uuid, workspace_uuid, name, description, owner_uuid, avatar_hash, + status, created_at, updated_at, deleted_at + FROM teams + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询用户在工作空间中所属的所有团队(工作空间级别) +pub async fn fetch_user_teams( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TeamDto>( + r#" + SELECT t.id, t.uuid, t.workspace_uuid, t.name, t.description, t.owner_uuid, t.avatar_hash, + t.status, t.created_at, t.updated_at, t.deleted_at + FROM teams t + INNER JOIN team_members tm ON t.uuid = tm.team_uuid + WHERE tm.workspace_uuid = $1 AND tm.user_uuid = $2 AND t.deleted_at IS NULL AND tm.deleted_at IS NULL + ORDER BY t.created_at + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询用户当前团队 +pub async fn fetch_user_current_team( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec: Option = sqlx::query_scalar( + r#" + SELECT current_team_uuid FROM user_infos + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 设置用户当前团队 +pub async fn set_user_current_team( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = $1 WHERE user_uuid = $2 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清除用户当前团队 +pub async fn clear_user_current_team(pool: &Pool, user_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = NULL WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新团队信息 +pub async fn update_team( + pool: &Pool, + team_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + avatar_hash: Option<&str>, +) -> Result<(), Error> { + let mut query = String::from("UPDATE teams SET updated_at = NOW()"); + let mut params: Vec + Send + Sync>> = vec![]; + + if let Some(n) = name { + query.push_str(", name = $"); + params.push(Box::new(n)); + query.push_str(&format!("{}", params.len())); + } + + if let Some(d) = description { + query.push_str(", description = $"); + params.push(Box::new(d)); + query.push_str(&format!("{}", params.len())); + } + + if let Some(a) = avatar_hash { + query.push_str(", avatar_hash = $"); + params.push(Box::new(a)); + query.push_str(&format!("{}", params.len())); + } + + query.push_str(" WHERE uuid = $"); + params.push(Box::new(team_uuid)); + query.push_str(&format!("{}", params.len())); + + // 这里需要动态构建查询,但 sqlx 不支持动态查询,所以使用条件分支 + if name.is_some() + && description.is_some() + && avatar_hash.is_some() + { + sqlx::query( + r#" + UPDATE teams + SET name = $1, description = $2, avatar_hash = $3, updated_at = NOW() + WHERE uuid = $4 + "#, + ) + .bind(name.unwrap()) + .bind(description.unwrap()) + .bind(avatar_hash.unwrap()) + .bind(team_uuid) + .execute(pool) + .await?; + } else if name.is_some() && description.is_some() { + sqlx::query( + r#" + UPDATE teams + SET name = $1, description = $2, updated_at = NOW() + WHERE uuid = $3 + "#, + ) + .bind(name.unwrap()) + .bind(description.unwrap()) + .bind(team_uuid) + .execute(pool) + .await?; + } else if name.is_some() { + sqlx::query( + r#" + UPDATE teams + SET name = $1, updated_at = NOW() + WHERE uuid = $2 + "#, + ) + .bind(name.unwrap()) + .bind(team_uuid) + .execute(pool) + .await?; + } + + Ok(()) +} + +// ============ Team Members ============ + +/// 获取团队成员数量 +pub async fn fetch_team_member_count( + pool: &Pool, + team_uuid: Uuid, + keyword: Option<&str>, + role: Option<&str>, + status: Option<&str>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(*) FROM team_members tm + LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid + WHERE tm.team_uuid = $1 AND tm.deleted_at IS NULL + "#, + ); + + let mut param_index = 2; + + // 默认只查询 active 状态 + if status.is_some() { + query.push_str(&format!(" AND tm.status = ${}", param_index)); + param_index += 1; + } else { + query.push_str(" AND tm.status = 'active'"); + } + + if role.is_some() { + query.push_str(&format!(" AND tm.role = ${}", param_index)); + param_index += 1; + } + + if keyword.is_some() { + query.push_str(&format!( + " AND (ui.nickname ILIKE ${} OR ui.email ILIKE ${})", + param_index, param_index + )); + } + + let mut query_builder = sqlx::query_scalar::<_, i64>(&query).bind(team_uuid); + + if let Some(s) = status { + query_builder = query_builder.bind(s); + } + + if let Some(r) = role { + query_builder = query_builder.bind(r); + } + + if let Some(k) = keyword { + let keyword_pattern = format!("%{}%", k); + query_builder = query_builder.bind(keyword_pattern); + } + + let count = query_builder.fetch_one(pool).await?; + + Ok(count) +} + +/// 查询团队成员列表(关联用户信息,支持筛选) +pub async fn fetch_team_members( + pool: &Pool, + team_uuid: Uuid, + offset: i64, + limit: i64, + keyword: Option<&str>, + role: Option<&str>, + status: Option<&str>, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT + tm.id, + tm.team_uuid, + tm.workspace_uuid, + tm.user_uuid, + tm.role, + tm.joined_at, + tm.invited_by, + tm.status, + tm.created_at, + tm.updated_at, + tm.deleted_at, + ui.nickname AS name, + ui.email AS email, + ui.avatar_hash AS avatar + FROM team_members tm + LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid + WHERE tm.team_uuid = $1 AND tm.deleted_at IS NULL + "#, + ); + + let mut param_index = 2; + + // 默认只查询 active 状态 + if status.is_some() { + query.push_str(&format!(" AND tm.status = ${}", param_index)); + param_index += 1; + } else { + query.push_str(" AND tm.status = 'active'"); + } + + if role.is_some() { + query.push_str(&format!(" AND tm.role = ${}", param_index)); + param_index += 1; + } + + if keyword.is_some() { + query.push_str(&format!( + " AND (ui.nickname ILIKE ${} OR ui.email ILIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + query.push_str(&format!( + r#" + ORDER BY + CASE tm.role + WHEN 'owner' THEN 1 + WHEN 'admin' THEN 2 + WHEN 'editor' THEN 3 + ELSE 4 + END, + tm.joined_at + LIMIT ${} OFFSET ${} + "#, + param_index, + param_index + 1 + )); + + let mut query_builder = sqlx::query_as::<_, TeamMemberDto>(&query).bind(team_uuid); + + if let Some(s) = status { + query_builder = query_builder.bind(s); + } + + if let Some(r) = role { + query_builder = query_builder.bind(r); + } + + if let Some(k) = keyword { + let keyword_pattern = format!("%{}%", k); + query_builder = query_builder.bind(keyword_pattern); + } + + query_builder = query_builder.bind(limit).bind(offset); + + let recs = query_builder.fetch_all(pool).await?; + + Ok(recs) +} + +/// 查询用户在团队中的成员信息(工作空间级别) +pub async fn fetch_team_member( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TeamMemberDto>( + r#" + SELECT + tm.id, + tm.team_uuid, + tm.workspace_uuid, + tm.user_uuid, + tm.role, + tm.joined_at, + tm.invited_by, + tm.status, + tm.created_at, + tm.updated_at, + tm.deleted_at, + ui.nickname AS name, + ui.email AS email, + ui.avatar_hash AS avatar + FROM team_members tm + LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid + WHERE tm.workspace_uuid = $1 AND tm.team_uuid = $2 AND tm.user_uuid = $3 AND tm.deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 添加团队成员 +pub async fn insert_team_member( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, + role: &str, + invited_by: Option, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, invited_by, status) + VALUES ($1, (SELECT workspace_uuid FROM teams WHERE uuid = $1), $2, $3, $4, 'active') + RETURNING id; + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(role) + .bind(invited_by) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 更新成员角色 +pub async fn update_member_role( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, + role: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_members + SET role = $1, updated_at = NOW() + WHERE team_uuid = $2 AND user_uuid = $3 AND deleted_at IS NULL + "#, + ) + .bind(role) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新成员状态 +pub async fn update_member_status( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_members + SET status = $1 + WHERE team_uuid = $2 AND user_uuid = $3 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除成员(软删除) +pub async fn remove_team_member( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_members + SET deleted_at = NOW(), status = 'inactive' + WHERE team_uuid = $1 AND user_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Team Invitations ============ + +/// 创建团队邀请 +pub async fn insert_team_invitation( + pool: &Pool, + team_uuid: Uuid, + email: &str, + role: &str, + invited_by: Uuid, + token: &str, + expires_at: chrono::DateTime, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO team_invitations (team_uuid, email, role, invited_by, token, expires_at, status) + VALUES ($1, $2, $3, $4, $5, $6, 'pending') + RETURNING uuid; + "#, + ) + .bind(team_uuid) + .bind(email) + .bind(role) + .bind(invited_by) + .bind(token) + .bind(expires_at) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询团队邀请 +pub async fn fetch_team_invitation_by_token( + pool: &Pool, + token: &str, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE token = $1 AND deleted_at IS NULL + "#, + ) + .bind(token) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询团队的待处理邀请 +pub async fn fetch_pending_invitations( + pool: &Pool, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE team_uuid = $1 AND status = 'pending' AND deleted_at IS NULL + ORDER BY created_at DESC + "#, + ) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 检查是否有待处理的邀请 +pub async fn has_pending_invitation( + pool: &Pool, + team_uuid: Uuid, + email: &str, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM team_invitations + WHERE team_uuid = $1 AND email = $2 AND status = 'pending' AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(email) + .fetch_one(pool) + .await?; + + Ok(count > 0) +} + +/// 更新邀请状态 +pub async fn update_invitation_status( + pool: &Pool, + invitation_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_invitations + SET status = $1, updated_at = NOW() + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(invitation_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询邀请(通过 token,别名函数) +pub async fn fetch_invitation_by_token( + pool: &Pool, + token: &str, +) -> Result, Error> { + fetch_team_invitation_by_token(pool, token).await +} + +/// 取消邀请 +pub async fn cancel_team_invitation( + pool: &Pool, + invitation_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_invitations + SET status = 'cancelled', deleted_at = NOW() + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(invitation_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 接受邀请 +pub async fn accept_team_invitation( + pool: &Pool, + invitation_uuid: Uuid, + user_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result { + let mut tx = pool.begin().await?; + + // 1. 获取邀请信息 + let invitation = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE uuid = $1 AND deleted_at IS NULL + FOR UPDATE + "#, + ) + .bind(invitation_uuid) + .fetch_optional(&mut *tx) + .await?; + + let invitation = invitation.ok_or_else(|| Error::RowNotFound)?; + + // 2. 检查邀请状态 + if invitation.status != "pending" { + return Err(Error::RowNotFound); + } + + // 3. 检查是否过期 + if invitation.expires_at < chrono::Utc::now() { + return Err(Error::RowNotFound); + } + + // 4. 添加成员(如果已存在则更新状态为 active) + sqlx::query( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, invited_by, status) + VALUES ($1, $2, $3, $4, $5, 'active') + ON CONFLICT (team_uuid, user_uuid, workspace_uuid) DO UPDATE SET + role = EXCLUDED.role, + invited_by = EXCLUDED.invited_by, + status = 'active', + deleted_at = NULL, + updated_at = NOW() + "#, + ) + .bind(invitation.team_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(&invitation.role) + .bind(invitation.invited_by) + .execute(&mut *tx) + .await?; + + // 5. 更新邀请状态 + sqlx::query( + r#" + UPDATE team_invitations + SET status = 'accepted', accepted_at = NOW() + WHERE uuid = $1 + "#, + ) + .bind(invitation_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(invitation.team_uuid) +} + +/// 拒绝邀请 +pub async fn reject_team_invitation( + pool: &Pool, + invitation_uuid: Uuid, + _user_uuid: Uuid, +) -> Result<(), Error> { + let mut tx = pool.begin().await?; + + // 1. 获取邀请信息 + let invitation = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE uuid = $1 AND deleted_at IS NULL + FOR UPDATE + "#, + ) + .bind(invitation_uuid) + .fetch_optional(&mut *tx) + .await?; + + let invitation = invitation.ok_or_else(|| Error::RowNotFound)?; + + // 2. 检查邀请状态(只有 pending 状态的邀请才能被拒绝) + if invitation.status != "pending" { + return Err(Error::RowNotFound); + } + + // 3. 检查是否过期(过期的邀请也可以标记为拒绝) + // 这里不检查过期时间,允许拒绝已过期的邀请 + + // 4. 更新邀请状态为 rejected + sqlx::query( + r#" + UPDATE team_invitations + SET status = 'rejected', updated_at = NOW() + WHERE uuid = $1 + "#, + ) + .bind(invitation_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) +} + +// ============ Login History ============ + +/// 记录登录历史 +pub async fn insert_login_history( + pool: &Pool, + user_uuid: Uuid, + ip_address: &str, + device_info: Option<&str>, + user_agent: Option<&str>, + location: Option<&str>, + country: Option<&str>, + city: Option<&str>, + success: bool, + failure_reason: Option<&str>, +) -> Result { + let id: i64 = sqlx::query_scalar( + r#" + INSERT INTO login_history (user_uuid, ip_address, device_info, user_agent, location, country, city, success, failure_reason) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(ip_address) + .bind(device_info) + .bind(user_agent) + .bind(location) + .bind(country) + .bind(city) + .bind(success) + .bind(failure_reason) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询用户登录历史 +pub async fn fetch_user_login_history( + pool: &Pool, + user_uuid: Uuid, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, LoginHistoryDto>( + r#" + SELECT id, user_uuid, ip_address, device_info, user_agent, location, country, city, success, failure_reason, created_at + FROM login_history + WHERE user_uuid = $1 + ORDER BY created_at DESC + LIMIT $2 + "#, + ) + .bind(user_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/server/src/models/user.rs b/server/src/models/user.rs new file mode 100644 index 00000000..02a9ad51 --- /dev/null +++ b/server/src/models/user.rs @@ -0,0 +1,533 @@ +use rust_decimal::Decimal; +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::{LocalApiPermissionDefinitionDto, UserDto, UserInfoDto}; +use crate::entitys::{RegisterRequest, UpdateUserRequest}; + +/// 插入用户基础信息 +pub async fn insert_user(pool: &Pool, user_id: String) -> Result { + let uuid: Uuid = sqlx::query_scalar("INSERT INTO users (id) VALUES ($1) RETURNING uuid;") + .bind(user_id) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 插入用户详细信息 +pub async fn insert_user_info( + pool: &Pool, + user_uuid: Uuid, + payload: &RegisterRequest, + password_hash: &str, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO user_infos (user_uuid, email, password, nickname, status) + VALUES ($1, $2, $3, $4, 'active') + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(&payload.email) + .bind(password_hash) + .bind(&payload.nickname) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 使用事务创建用户(users + user_infos + 初始化数据) +/// +/// 初始化数据包括: +/// - user_wallets: 用户钱包 +/// - user_quotas: 用户配额(免费套餐) +/// - user_preferences: 用户偏好设置 +/// - user_referral_points: 推广积分 +/// - referral_links: 推广链接(关联青铜层级) +/// - teams: 个人团队(每个用户自动创建一个团队) +pub async fn create_user_with_info( + pool: &Pool, + user_id: String, + payload: &RegisterRequest, + password_hash: &str, + quota: &crate::utils::WorkspaceQuotaValues, +) -> Result { + // 为用户初始化推广链接时,需要读取所有推广层级配置。 + // 这里复用 referral 模块中已经定义好的 DTO / 查询函数,避免在 models 中新增本地 struct 类型。 + let tiers = crate::models::referral::fetch_referral_tiers(pool).await?; + let local_api_permission_definitions = + crate::models::local_api::fetch_permission_definitions(pool).await?; + + let mut tx = pool.begin().await?; + + // 1. 创建用户基础记录 + let user_uuid: Uuid = sqlx::query_scalar("INSERT INTO users (id) VALUES ($1) RETURNING uuid;") + .bind(&user_id) + .fetch_one(&mut *tx) + .await?; + + // 2. 创建用户详细信息 + sqlx::query( + r#" + INSERT INTO user_infos (user_uuid, email, password, nickname, status) + VALUES ($1, $2, $3, $4, 'active'); + "#, + ) + .bind(user_uuid) + .bind(&payload.email) + .bind(password_hash) + .bind(&payload.nickname) + .execute(&mut *tx) + .await?; + + // 3. 初始化用户钱包 + sqlx::query( + r#" + INSERT INTO user_wallets (user_uuid, balance, currency, frozen_amount) + VALUES ($1, 0, 'CNY', 0); + "#, + ) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 4. 初始化用户配额(已废弃,配额移至 workspace_quotas,在工作空间创建时初始化) + + // 5. 初始化用户偏好设置 + sqlx::query( + r#" + INSERT INTO user_preferences (user_uuid, theme, language, notifications_enabled) + VALUES ($1, 'system', 'zh-CN', TRUE); + "#, + ) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 6. 初始化本地 API 配置 + sqlx::query( + r#" + INSERT INTO user_local_api_settings (user_uuid, enabled, port, remote_access, cors_origins) + VALUES ($1, FALSE, 8080, FALSE, '[]'::jsonb); + "#, + ) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 7. 初始化本地 API 密钥 + let local_api_key = generate_local_api_key(); + let local_api_key_hash = crate::models::local_api::hash_api_key(&local_api_key); + let local_api_key_prefix = local_api_key.chars().take(16).collect::(); + let local_api_key_id: i32 = sqlx::query_scalar( + r#" + INSERT INTO user_local_api_keys (user_uuid, key_prefix, key_hash, api_key, daily_limit) + VALUES ($1, $2, $3, $4, 1000) + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(&local_api_key_prefix) + .bind(&local_api_key_hash) + .bind(&local_api_key) + .fetch_one(&mut *tx) + .await?; + + // 8. 初始化本地 API 权限记录 + insert_local_api_permissions( + &mut tx, + local_api_key_id, + &local_api_permission_definitions, + ) + .await?; + + // 9. 初始化推广积分 + sqlx::query( + r#" + INSERT INTO user_referral_points (user_uuid, total_points, available_points, used_points, pending_points) + VALUES ($1, 0, 0, 0, 0); + "#, + ) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 10. 创建推广链接(根据层级配置创建最多 4 个链接) + // + // 规则: + // - 始终为用户预创建所有层级对应的推广链接(默认最多 4 个) + // - 只有第一个层级的链接默认解锁并设为当前链接 + // - 奖励比例 / 折扣比例从对应层级配置中继承,便于后续统一调整 + // + // 生成基础邀请码(第一个链接使用此码,其余在此基础上追加序号后缀) + let base_invite_code = format!("INV{}", &user_uuid.to_string()[..8].to_uppercase()); + + // 根据层级配置为用户预创建推广链接(最多 4 个) + for (idx, tier) in tiers.iter().enumerate() { + if idx >= 4 { + // 目前产品形态只需要 4 个推广链接,多余层级忽略 + break; + } + + let code = if idx == 0 { + base_invite_code.clone() + } else { + // 保证唯一性的同时,便于用户识别不同链接 + format!("{}-{}", base_invite_code, idx + 1) + }; + + // 只有第一个层级默认解锁并设为当前链接,其余待后续根据阈值自动解锁 + let is_current = idx == 0; + let unlocked = idx == 0; + + sqlx::query( + r#" + INSERT INTO referral_links ( + user_uuid, + code, + tier_uuid, + is_current, + unlocked, + reward_rate, + discount_rate + ) + VALUES ($1, $2, $3, $4, $5, $6, $7); + "#, + ) + .bind(user_uuid) + .bind(&code) + .bind(tier.uuid) + .bind(is_current) + .bind(unlocked) + .bind(tier.reward_rate) + .bind(tier.discount_rate) + .execute(&mut *tx) + .await?; + } + + // 11. 处理推荐人关系(如果有邀请码) + if let Some(ref referrer_code) = payload.referral_code { + // 查找推荐人 + let referrer: Option<(Uuid, Uuid)> = sqlx::query_as( + r#" + SELECT user_uuid, uuid FROM referral_links WHERE code = $1 AND is_current = TRUE; + "#, + ) + .bind(referrer_code) + .fetch_optional(&mut *tx) + .await?; + + if let Some((referrer_uuid, referral_link_uuid)) = referrer { + // 创建推荐关系(表列名:inviter_uuid=推荐人, invitee_uuid=被邀请人, link_uuid=推广链接) + sqlx::query( + r#" + INSERT INTO user_referrals (invitee_uuid, inviter_uuid, link_uuid, status) + VALUES ($1, $2, $3, 'registered'); + "#, + ) + .bind(user_uuid) + .bind(referrer_uuid) + .bind(referral_link_uuid) + .execute(&mut *tx) + .await?; + + // 更新推荐链接统计 + sqlx::query( + r#" + UPDATE referral_links SET registered_users = registered_users + 1 WHERE uuid = $1; + "#, + ) + .bind(referral_link_uuid) + .execute(&mut *tx) + .await?; + } + } + + // 12. 创建个人团队(每个用户都应该有一个团队) + // 团队名称使用用户昵称,如果没有昵称则使用邮箱前缀 + let team_name = + payload.nickname.as_ref().map(|n| format!("{} 的团队", n)).unwrap_or_else(|| { + format!( + "{} 的团队", + payload.email.split('@').next().unwrap_or("用户") + ) + }); + + // 13. 创建个人工作空间 + let workspace_name = format!( + "{} 的工作空间", + payload.nickname.as_deref().unwrap_or("用户") + ); + let workspace_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO workspaces (name, owner_uuid, workspace_type) + VALUES ($1, $2, 'personal') + RETURNING uuid; + "#, + ) + .bind(&workspace_name) + .bind(user_uuid) + .fetch_one(&mut *tx) + .await?; + + // 14. 创建工作空间配额(使用传入的配额配置) + sqlx::query( + r#" + INSERT INTO workspace_quotas (workspace_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks) + VALUES ($1, $2, $3, $4, $5); + "#, + ) + .bind(workspace_uuid) + .bind(quota.max_environments) + .bind(quota.max_team_members) + .bind(quota.max_proxies) + .bind(quota.max_rpa_tasks) + .execute(&mut *tx) + .await?; + + // 15. 创建个人团队(每个用户自动创建一个团队) + let team_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO teams (workspace_uuid, name, description, owner_uuid) + VALUES ($1, $2, $3, $4) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(&team_name) + .bind(Some("个人团队")) + .bind(user_uuid) + .fetch_one(&mut *tx) + .await?; + + // 16. 添加用户为团队成员(owner 角色) + sqlx::query( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, status) + VALUES ($1, $2, $3, 'owner', 'active'); + "#, + ) + .bind(team_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 17. 设置为用户当前团队和工作空间 + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = $1, current_workspace_uuid = $2 WHERE user_uuid = $3; + "#, + ) + .bind(team_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(user_uuid) +} + +async fn insert_local_api_permissions( + tx: &mut sqlx::Transaction<'_, Postgres>, + api_key_id: i32, + definitions: &[LocalApiPermissionDefinitionDto], +) -> Result<(), Error> { + for definition in definitions { + sqlx::query( + r#" + INSERT INTO user_local_api_key_permissions ( + api_key_id, permission_code, is_enabled, rate_limit_per_minute, rate_limit_per_hour + ) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (api_key_id, permission_code) DO NOTHING; + "#, + ) + .bind(api_key_id) + .bind(definition.permission_code.as_str()) + .bind(definition.default_enabled) + .bind(definition.default_rate_limit_per_minute) + .bind(definition.default_rate_limit_per_hour) + .execute(&mut **tx) + .await?; + } + + Ok(()) +} + +fn generate_local_api_key() -> String { + let raw = Uuid::new_v4().simple().to_string(); + format!("sk_local_{}", raw) +} + + +/// 根据 UUID 查询用户 +pub async fn fetch_user_by_uuid( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserDto>( + r#" + SELECT uuid, id, created_at, updated_at, deleted_at + FROM users + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询用户详细信息 +pub async fn fetch_user_info_by_uuid( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserInfoDto>( + r#" + SELECT id, user_uuid, nickname, email, phone, password, avatar_hash, status, + current_team_uuid, current_workspace_uuid, created_at, updated_at, deleted_at + FROM user_infos + WHERE user_uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 根据邮箱查询用户详细信息 +pub async fn fetch_user_info_by_email( + pool: &Pool, + email: &str, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserInfoDto>( + r#" + SELECT id, user_uuid, nickname, email, phone, password, avatar_hash, status, + current_team_uuid, current_workspace_uuid, created_at, updated_at, deleted_at + FROM user_infos + WHERE email = $1 AND deleted_at IS NULL + "#, + ) + .bind(email) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新用户信息 +pub async fn update_user_info( + pool: &Pool, + user_uuid: Uuid, + payload: &UpdateUserRequest, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos + SET nickname = COALESCE($1, nickname), + phone = COALESCE($2, phone), + email = COALESCE($3, email), + updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(payload.nickname.as_deref()) + .bind(payload.phone.as_deref()) + .bind(payload.email.as_deref()) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新密码 +pub async fn update_password( + pool: &Pool, + user_uuid: Uuid, + password_hash: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos + SET password = $1 + WHERE user_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(password_hash) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 设置用户当前工作空间 +pub async fn fetch_user_current_workspace( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec: Option = sqlx::query_scalar( + r#" + SELECT current_workspace_uuid FROM user_infos + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +pub async fn set_user_current_workspace( + pool: &Pool, + user_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos SET current_workspace_uuid = $1 WHERE user_uuid = $2 + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn set_user_current_workspace_and_team( + pool: &Pool, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos + SET current_workspace_uuid = $1, current_team_uuid = $2 + WHERE user_uuid = $3 + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/server/src/models/version_types.rs b/server/src/models/version_types.rs new file mode 100644 index 00000000..418819b5 --- /dev/null +++ b/server/src/models/version_types.rs @@ -0,0 +1,126 @@ +use crate::dto::version_types::VersionType; +use crate::entitys::version_types::{CreateVersionTypeRequest, UpdateVersionTypeRequest}; +use sqlx::Error; + +use crate::database::{Db as Postgres, Pool}; + +/// 插入新版本类型 +pub async fn insert_version_type( + pool: &Pool, + request: &CreateVersionTypeRequest, +) -> Result { + let sql = " + INSERT INTO version_types ( + type_code, type_name, description, sort_order, is_active + ) VALUES ( + $1, $2, $3, $4, true + ) RETURNING id + "; + + let result: (i32,) = sqlx::query_as(sql) + .bind(&request.type_code) + .bind(&request.type_name) + .bind(&request.description) + .bind(request.sort_order.unwrap_or(0)) + .fetch_one(pool) + .await?; + + Ok(result.0) +} + +/// 根据ID查询版本类型 +pub async fn query_version_type_by_id( + pool: &Pool, + id: i32, +) -> Result { + let version_type: VersionType = sqlx::query_as("SELECT * FROM version_types WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; + + Ok(version_type) +} + +/// 根据代码查询版本类型 +pub async fn query_version_type_by_code( + pool: &Pool, + type_code: &str, +) -> Result { + let version_type: VersionType = + sqlx::query_as("SELECT * FROM version_types WHERE type_code = $1") + .bind(type_code) + .fetch_one(pool) + .await?; + + Ok(version_type) +} + +/// 查询所有版本类型 +pub async fn query_all_version_types(pool: &Pool) -> Result, Error> { + let version_types: Vec = + sqlx::query_as("SELECT * FROM version_types ORDER BY sort_order ASC, id ASC") + .fetch_all(pool) + .await?; + + Ok(version_types) +} + +/// 查询激活的版本类型 +pub async fn query_active_version_types(pool: &Pool) -> Result, Error> { + let version_types: Vec = sqlx::query_as( + "SELECT * FROM version_types WHERE is_active = true ORDER BY sort_order ASC, id ASC", + ) + .fetch_all(pool) + .await?; + + Ok(version_types) +} + +/// 更新版本类型 +pub async fn update_version_type( + pool: &Pool, + id: i32, + request: &UpdateVersionTypeRequest, +) -> Result { + let sql = " + UPDATE version_types SET + type_name = COALESCE($1, type_name), + description = COALESCE($2, description), + sort_order = COALESCE($3, sort_order), + is_active = COALESCE($4, is_active) + WHERE id = $5 + "; + + let row = sqlx::query(sql) + .bind(&request.type_name) + .bind(&request.description) + .bind(request.sort_order) + .bind(request.is_active) + .bind(id) + .execute(pool) + .await?; + + Ok(row.rows_affected() == 1) +} + +/// 删除版本类型 +pub async fn delete_version_type(pool: &Pool, id: i32) -> Result { + let sql = "DELETE FROM version_types WHERE id = $1"; + let row = sqlx::query(sql).bind(id).execute(pool).await?; + Ok(row.rows_affected() == 1) +} + +/// 激活/停用版本类型 +pub async fn toggle_version_type_status( + pool: &Pool, + id: i32, + is_active: bool, +) -> Result { + let sql = "UPDATE version_types SET is_active = $1 WHERE id = $2"; + let row = sqlx::query(sql) + .bind(is_active) + .bind(id) + .execute(pool) + .await?; + Ok(row.rows_affected() == 1) +} diff --git a/server/src/models/versions.rs b/server/src/models/versions.rs new file mode 100644 index 00000000..093e61ea --- /dev/null +++ b/server/src/models/versions.rs @@ -0,0 +1,551 @@ +use crate::dto::versions::Version; +use crate::entitys::versions::{CreateVersionRequest, UpdateVersionRequest}; +use chrono::{DateTime, Utc}; +use sqlx::Error; + +use crate::database::{Db as Postgres, Pool}; + +/// 插入新版本 +pub async fn insert_version( + pool: &Pool, + request: &CreateVersionRequest, +) -> Result { + let sql = " + INSERT INTO versions ( + type_id, resource_name, version, name, notes, platform, url, hash, + signature, install_path, file_size, pub_date, + arch, package_format, requires_extract, created_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, + $13, $14, $15, NOW() + ) RETURNING id + "; + + let result: (i32,) = sqlx::query_as(sql) + .bind(request.type_id) + .bind(&request.resource_name) + .bind(&request.version) + .bind(&request.name) + .bind(&request.notes) + .bind(&request.platform) + .bind(&request.url) + .bind(&request.hash) + .bind(&request.signature) + .bind(&request.install_path) + .bind(request.file_size) + .bind(request.pub_date) + .bind(&request.arch) + .bind(&request.package_format) + .bind(request.requires_extract.unwrap_or(true)) + .fetch_one(pool) + .await?; + + Ok(result.0) +} + +/// 根据ID查询版本 +pub async fn query_version_by_id(pool: &Pool, id: i32) -> Result { + let version: Version = sqlx::query_as("SELECT * FROM versions WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?; + + Ok(version) +} + +/// 根据资源名称和版本号查询 +pub async fn query_version_by_name_and_version( + pool: &Pool, + resource_name: &str, + version: &str, +) -> Result { + let version_data: Version = + sqlx::query_as("SELECT * FROM versions WHERE resource_name = $1 AND version = $2") + .bind(resource_name) + .bind(version) + .fetch_one(pool) + .await?; + + Ok(version_data) +} + +/// 查询最新版本 +pub async fn query_latest_version( + pool: &Pool, + resource_name: &str, + platform: &str, +) -> Result, Error> { + let version: Option = sqlx::query_as( + "SELECT * FROM versions + WHERE resource_name = $1 AND platform = $2 AND status = 'active' + ORDER BY pub_date DESC LIMIT 1", + ) + .bind(resource_name) + .bind(platform) + .fetch_optional(pool) + .await?; + + Ok(version) +} + +/// 查询版本列表(分页) +pub async fn query_versions( + pool: &Pool, + resource_name: Option<&str>, + platform: Option<&str>, + status: Option<&str>, + page_num: i32, + page_size: i32, +) -> Result<(i64, Vec), Error> { + // 构建查询条件 + let mut where_clauses = vec!["deleted_at IS NULL".to_string()]; + let mut param_index = 1; + + if let Some(_name) = resource_name { + where_clauses.push(format!("resource_name = ${}", param_index)); + param_index += 1; + } + if let Some(_plat) = platform { + where_clauses.push(format!("platform = ${}", param_index)); + param_index += 1; + } + if let Some(_st) = status { + where_clauses.push(format!("status = ${}", param_index)); + param_index += 1; + } + + let where_sql = where_clauses.join(" AND "); + + // 获取总数 + let count_sql = format!("SELECT COUNT(*) FROM versions WHERE {}", where_sql); + let count_result: (i64,) = match (resource_name, platform, status) { + (Some(n), Some(p), Some(s)) => { + sqlx::query_as(&count_sql) + .bind(n) + .bind(p) + .bind(s) + .fetch_one(pool) + .await? + } + (Some(n), Some(p), None) => { + sqlx::query_as(&count_sql) + .bind(n) + .bind(p) + .fetch_one(pool) + .await? + } + (Some(n), None, Some(s)) => { + sqlx::query_as(&count_sql) + .bind(n) + .bind(s) + .fetch_one(pool) + .await? + } + (Some(n), None, None) => sqlx::query_as(&count_sql).bind(n).fetch_one(pool).await?, + (None, Some(p), Some(s)) => { + sqlx::query_as(&count_sql) + .bind(p) + .bind(s) + .fetch_one(pool) + .await? + } + (None, Some(p), None) => sqlx::query_as(&count_sql).bind(p).fetch_one(pool).await?, + (None, None, Some(s)) => sqlx::query_as(&count_sql).bind(s).fetch_one(pool).await?, + (None, None, None) => sqlx::query_as(&count_sql).fetch_one(pool).await?, + }; + + // 获取分页列表 + let list_sql = format!( + "SELECT * FROM versions WHERE {} ORDER BY created_at DESC LIMIT ${} OFFSET ${}", + where_sql, + param_index, + param_index + 1 + ); + let versions: Vec = match (resource_name, platform, status) { + (Some(n), Some(p), Some(s)) => { + sqlx::query_as(&list_sql) + .bind(n) + .bind(p) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(n), Some(p), None) => { + sqlx::query_as(&list_sql) + .bind(n) + .bind(p) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(n), None, Some(s)) => { + sqlx::query_as(&list_sql) + .bind(n) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (Some(n), None, None) => { + sqlx::query_as(&list_sql) + .bind(n) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, Some(p), Some(s)) => { + sqlx::query_as(&list_sql) + .bind(p) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, Some(p), None) => { + sqlx::query_as(&list_sql) + .bind(p) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, None, Some(s)) => { + sqlx::query_as(&list_sql) + .bind(s) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + (None, None, None) => { + sqlx::query_as(&list_sql) + .bind(page_size) + .bind((page_num - 1) * page_size) + .fetch_all(pool) + .await? + } + }; + + Ok((count_result.0, versions)) +} + +/// 更新版本 +pub async fn update_version( + pool: &Pool, + id: i32, + request: &UpdateVersionRequest, +) -> Result { + let sql = " + UPDATE versions SET + name = COALESCE($1, name), + notes = COALESCE($2, notes), + platform = COALESCE($3, platform), + url = COALESCE($4, url), + hash = COALESCE($5, hash), + signature = COALESCE($6, signature), + install_path = COALESCE($7, install_path), + file_size = COALESCE($8, file_size), + status = COALESCE($9, status), + is_latest = COALESCE($10, is_latest), + updated_at = NOW() + WHERE id = $11 + "; + + let row = sqlx::query(sql) + .bind(&request.name) + .bind(&request.notes) + .bind(&request.platform) + .bind(&request.url) + .bind(&request.hash) + .bind(&request.signature) + .bind(&request.install_path) + .bind(request.file_size) + .bind(&request.status) + .bind(request.is_latest) + .bind(id) + .execute(pool) + .await?; + + Ok(row.rows_affected() == 1) +} + +/// 软删除版本 +pub async fn delete_version(pool: &Pool, id: i32) -> Result { + // 非逻辑删除 + let sql = "DELETE FROM versions WHERE id = $1"; + let row = sqlx::query(sql).bind(id).execute(pool).await?; + Ok(row.rows_affected() == 1) +} + +/// 设置某个资源为最新版本 +pub async fn set_as_latest_version( + pool: &Pool, + type_id: i32, + resource_name: &str, + version_id: i32, +) -> Result { + let mut tx = pool.begin().await?; + + // 先取消其他版本的最新状态 + sqlx::query( + "UPDATE versions SET is_latest = false, updated_at = NOW() + WHERE type_id = $1 AND resource_name = $2 AND id != $3", + ) + .bind(type_id) + .bind(resource_name) + .bind(version_id) + .execute(&mut *tx) + .await?; + + // 设置当前版本为最新 + let row = sqlx::query("UPDATE versions SET is_latest = true, updated_at = NOW() WHERE id = $1") + .bind(version_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(row.rows_affected() == 1) +} + +/// 查询所有激活版本类型对应平台的最新版本 +/// 使用 is_latest 字段优先,按 (type_code, resource_name) 分组 +/// 返回 (type_code, resource_name, Version) 元组列表 +pub async fn query_all_active_latest_versions( + pool: &Pool, + platform: &str, +) -> Result, Error> { + // 自定义结构体用于接收查询结果 + #[derive(sqlx::FromRow)] + struct VersionWithTypeCode { + type_code: String, + // Version 的所有字段 + id: i32, + type_id: i32, + resource_name: String, + version: String, + name: Option, + notes: Option, + platform: Option, + url: Option, + hash: Option, + signature: Option, + install_path: Option, + file_size: Option, + is_latest: bool, + status: String, + pub_date: Option>, + created_at: DateTime, + updated_at: Option>, + deleted_at: Option>, + arch: Option, + package_format: Option, + requires_extract: bool, + entrypoint_template: Option, + extract_root: Option, + } + + let results: Vec = sqlx::query_as( + r#" + SELECT DISTINCT ON (vt.type_code, v.resource_name) + vt.type_code, + v.id, v.type_id, v.resource_name, v.version, v.name, v.notes, + v.platform, v.url, v.hash, v.signature, v.install_path, + v.file_size, v.is_latest, v.status, v.pub_date, + v.created_at, v.updated_at, v.deleted_at, + v.arch, v.package_format, v.requires_extract, v.entrypoint_template, v.extract_root + FROM versions v + INNER JOIN version_types vt ON v.type_id = vt.id + WHERE vt.is_active = true + AND v.platform = $1 + AND v.status = 'active' + AND v.deleted_at IS NULL + ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, v.id DESC + "#, + ) + .bind(platform) + .fetch_all(pool) + .await?; + + // 转换为 (String, String, Version) 元组 + let converted: Vec<(String, String, Version)> = results + .into_iter() + .map(|r| { + ( + r.type_code.clone(), + r.resource_name.clone(), + Version { + id: r.id, + type_id: r.type_id, + resource_name: r.resource_name, + version: r.version, + name: r.name.clone(), + notes: r.notes, + platform: r.platform, + url: r.url, + hash: r.hash, + signature: r.signature, + install_path: r.install_path, + file_size: r.file_size, + is_latest: r.is_latest, + status: r.status, + pub_date: r.pub_date, + created_at: r.created_at, + updated_at: r.updated_at, + deleted_at: r.deleted_at, + arch: r.arch, + package_format: r.package_format, + requires_extract: r.requires_extract, + entrypoint_template: r.entrypoint_template, + extract_root: r.extract_root, + }, + ) + }) + .collect(); + + Ok(converted) +} + +/// 查询浏览器内核类型的最新版本列表 +/// type_code_filter 为 None 时筛选 type_code LIKE 'SIMPRINT_KERNEL_%',为 Some 时精确匹配 +/// 按 (type_code, resource_name) 分组,每组取最新版本 +/// 最新判定:is_latest 优先,其次 COALESCE(pub_date, created_at),最后 id +/// platform 为可选,None 表示不按平台过滤 +pub async fn query_browser_kernel_latest_versions( + pool: &Pool, + platform: Option<&str>, + type_code_filter: Option<&str>, +) -> Result, Error> { + #[derive(sqlx::FromRow)] + struct VersionWithTypeCode { + type_code: String, + id: i32, + type_id: i32, + resource_name: String, + version: String, + name: Option, + notes: Option, + platform: Option, + url: Option, + hash: Option, + signature: Option, + install_path: Option, + file_size: Option, + is_latest: bool, + status: String, + pub_date: Option>, + created_at: DateTime, + updated_at: Option>, + deleted_at: Option>, + arch: Option, + package_format: Option, + requires_extract: bool, + entrypoint_template: Option, + extract_root: Option, + } + + const SELECT_AND_ORDER: &str = r#" + SELECT DISTINCT ON (vt.type_code, v.resource_name) + vt.type_code, + v.id, v.type_id, v.resource_name, v.version, v.name, v.notes, + v.platform, v.url, v.hash, v.signature, v.install_path, + v.file_size, v.is_latest, v.status, v.pub_date, + v.created_at, v.updated_at, v.deleted_at, + v.arch, v.package_format, v.requires_extract, v.entrypoint_template, v.extract_root + FROM versions v + INNER JOIN version_types vt ON v.type_id = vt.id + "#; + + let results: Vec = match (platform, type_code_filter) { + (Some(plat), Some(tc)) => { + sqlx::query_as(&format!( + "{} WHERE vt.type_code = $1 AND vt.is_active = true AND v.platform = $2 \ + AND v.status = 'active' AND v.deleted_at IS NULL \ + ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, \ + COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, v.id DESC", + SELECT_AND_ORDER + )) + .bind(tc) + .bind(plat) + .fetch_all(pool) + .await? + } + (Some(plat), None) => { + sqlx::query_as(&format!( + "{} WHERE vt.type_code LIKE 'SIMPRINT_KERNEL_%' AND vt.is_active = true \ + AND v.platform = $1 AND v.status = 'active' AND v.deleted_at IS NULL \ + ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, \ + COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, v.id DESC", + SELECT_AND_ORDER + )) + .bind(plat) + .fetch_all(pool) + .await? + } + (None, Some(tc)) => { + sqlx::query_as(&format!( + "{} WHERE vt.type_code = $1 AND vt.is_active = true \ + AND v.status = 'active' AND v.deleted_at IS NULL \ + ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, \ + COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, v.id DESC", + SELECT_AND_ORDER + )) + .bind(tc) + .fetch_all(pool) + .await? + } + (None, None) => { + sqlx::query_as(&format!( + "{} WHERE vt.type_code LIKE 'SIMPRINT_KERNEL_%' AND vt.is_active = true \ + AND v.status = 'active' AND v.deleted_at IS NULL \ + ORDER BY vt.type_code, v.resource_name, v.is_latest DESC, \ + COALESCE(v.pub_date, v.created_at) DESC NULLS LAST, v.id DESC", + SELECT_AND_ORDER + )) + .fetch_all(pool) + .await? + } + }; + + let converted: Vec<(String, String, Version)> = results + .into_iter() + .map(|r| { + ( + r.type_code.clone(), + r.resource_name.clone(), + Version { + id: r.id, + type_id: r.type_id, + resource_name: r.resource_name, + version: r.version, + name: r.name.clone(), + notes: r.notes, + platform: r.platform, + url: r.url, + hash: r.hash, + signature: r.signature, + install_path: r.install_path, + file_size: r.file_size, + is_latest: r.is_latest, + status: r.status, + pub_date: r.pub_date, + created_at: r.created_at, + updated_at: r.updated_at, + deleted_at: r.deleted_at, + arch: r.arch, + package_format: r.package_format, + requires_extract: r.requires_extract, + entrypoint_template: r.entrypoint_template, + extract_root: r.extract_root, + }, + ) + }) + .collect(); + + Ok(converted) +} diff --git a/server/src/models/workspace_quotas.rs b/server/src/models/workspace_quotas.rs new file mode 100644 index 00000000..863f7056 --- /dev/null +++ b/server/src/models/workspace_quotas.rs @@ -0,0 +1,235 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::WorkspaceQuotaDto; + +/// 创建或更新工作空间配额 +pub async fn insert_or_update_workspace_quota( + pool: &Pool, + workspace_uuid: Uuid, + max_environments: i32, + max_team_members: i32, + max_proxies: i32, + max_rpa_tasks: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO workspace_quotas ( + workspace_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks + ) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (workspace_uuid) DO UPDATE SET + max_environments = EXCLUDED.max_environments, + max_team_members = EXCLUDED.max_team_members, + max_proxies = EXCLUDED.max_proxies, + max_rpa_tasks = EXCLUDED.max_rpa_tasks, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(workspace_uuid) + .bind(max_environments) + .bind(max_team_members) + .bind(max_proxies) + .bind(max_rpa_tasks) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询工作空间配额 +pub async fn fetch_workspace_quota( + pool: &Pool, + workspace_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, WorkspaceQuotaDto>( + r#" + SELECT workspace_uuid, max_environments, used_environments, + max_team_members, used_team_members, + max_proxies, used_proxies, + max_rpa_tasks, used_rpa_tasks, + created_at, updated_at + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 增加环境使用数 +pub async fn increment_used_environments( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_environments = used_environments + $1, + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 减少环境使用数 +pub async fn decrement_used_environments( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_environments = GREATEST(0, used_environments - $1), + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加代理使用数 +pub async fn increment_used_proxies( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_proxies = used_proxies + $1, + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 减少代理使用数 +pub async fn decrement_used_proxies( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_proxies = GREATEST(0, used_proxies - $1), + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新团队成员使用数(统计所有团队的活跃成员) +pub async fn update_used_team_members( + pool: &Pool, + workspace_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas wq + SET used_team_members = ( + SELECT COUNT(DISTINCT tm.user_uuid) + FROM team_members tm + INNER JOIN teams t ON tm.team_uuid = t.uuid + WHERE t.workspace_uuid = wq.workspace_uuid + AND tm.deleted_at IS NULL + AND t.deleted_at IS NULL + ), + updated_at = CURRENT_TIMESTAMP + WHERE wq.workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 检查配额是否充足 +pub async fn check_quota( + pool: &Pool, + workspace_uuid: Uuid, + quota_type: &str, +) -> Result { + let result = match quota_type { + "environments" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_environments < max_environments + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + "proxies" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_proxies < max_proxies + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + "team_members" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_team_members < max_team_members + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + "rpa_tasks" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_rpa_tasks < max_rpa_tasks + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + _ => false, + }; + + Ok(result) +} diff --git a/server/src/models/workspaces.rs b/server/src/models/workspaces.rs new file mode 100644 index 00000000..3f07957c --- /dev/null +++ b/server/src/models/workspaces.rs @@ -0,0 +1,123 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db as Postgres, Pool}; + +use crate::dto::WorkspaceDto; +use crate::entitys::CreateWorkspaceRequest; + +/// 创建工作空间 +pub async fn insert_workspace( + pool: &Pool, + owner_uuid: Uuid, + payload: &CreateWorkspaceRequest, +) -> Result { + let workspace_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO workspaces (name, owner_uuid, workspace_type) + VALUES ($1, $2, $3) + RETURNING uuid; + "#, + ) + .bind(&payload.name) + .bind(owner_uuid) + .bind(payload.workspace_type.as_deref().unwrap_or("personal")) + .fetch_one(pool) + .await?; + + Ok(workspace_uuid) +} + +/// 根据 UUID 查询工作空间 +pub async fn fetch_workspace_by_uuid( + pool: &Pool, + workspace_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, WorkspaceDto>( + r#" + SELECT uuid, name, owner_uuid, workspace_type, created_at, updated_at, deleted_at + FROM workspaces + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询用户所属的所有工作空间 +pub async fn fetch_user_workspaces( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, WorkspaceDto>( + r#" + SELECT uuid, name, owner_uuid, workspace_type, created_at, updated_at, deleted_at + FROM workspaces + WHERE owner_uuid = $1 AND deleted_at IS NULL + ORDER BY created_at + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 更新工作空间 +pub async fn update_workspace( + pool: &Pool, + workspace_uuid: Uuid, + name: Option<&str>, +) -> Result<(), Error> { + if let Some(name) = name { + sqlx::query( + r#" + UPDATE workspaces SET name = $1 WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(workspace_uuid) + .execute(pool) + .await?; + } + + Ok(()) +} + +/// 删除工作空间(软删除) +pub async fn delete_workspace(pool: &Pool, workspace_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspaces SET deleted_at = CURRENT_TIMESTAMP WHERE uuid = $1 + "#, + ) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 检查用户是否是工作空间所有者 +pub async fn check_workspace_owner( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM workspaces + WHERE uuid = $1 AND owner_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + Ok(count > 0) +} diff --git a/server/src/routes.rs b/server/src/routes.rs new file mode 100644 index 00000000..a18c4dc8 --- /dev/null +++ b/server/src/routes.rs @@ -0,0 +1,169 @@ +pub mod health; +pub mod secret; +pub mod time; +pub mod users; + +// 新增模块 +pub mod accounts; +pub mod local_api; +pub mod audit; +pub mod billing; +pub mod browser_kernel; +pub mod environments; +pub mod extensions; +pub mod group_permissions; +pub mod messages; +pub mod preferences; +pub mod proxies; +pub mod proxy_visibility; +pub mod referral; +pub mod rpa; +pub mod teams; +pub mod templates; +pub mod workspace_quotas; +pub mod workspaces; + +// route core +pub mod route { + use std::borrow::Cow; + + use axum::{Router, routing::MethodRouter}; + + use crate::svc_ctx::SvcCtx; + + // can support request methods enum + #[derive(Debug, Clone)] + pub enum RequestMethod { + GET, + POST, + PUT, + DELETE, + PATCH, + } + + /// route item + pub struct RouteItem { + pub path: &'static str, + pub method: RequestMethod, + pub handler: MethodRouter, + } + + /// route group + pub struct RouteGroup { + pub prefix: &'static str, + pub routes: Vec, + } + + /// meta route + pub struct MetaRoute { + pub prefix: Cow<'static, str>, + pub routes: Vec, + } + + impl MetaRoute { + pub fn new(prefix: String) -> Self { + Self { + prefix: Cow::Owned(prefix), + routes: vec![], + } + } + + pub fn add_route_group(&mut self, group: RouteGroup) -> () { + self.routes.push(group); + } + + pub fn count(&self) -> usize { + self.routes.iter().map(|group| group.routes.len()).sum() + } + + pub fn build(&self) -> Router { + let mut root_router_child = Router::new(); + + for contain in &self.routes { + let RouteGroup { prefix, routes } = contain; + + let parent_prefix = prefix.to_string(); + let mut child_router = Router::new(); + + for item in routes { + let RouteItem { + path, + method, + handler, + } = item; + + tracing::info!("{:?}+{}{}{}", method, self.prefix, parent_prefix, path,); + match method { + RequestMethod::GET => { + child_router = child_router.route(path, handler.clone()); + } + RequestMethod::POST => { + child_router = child_router.route(path, handler.clone()); + } + RequestMethod::PUT => { + child_router = child_router.route(path, handler.clone()); + } + RequestMethod::DELETE => { + child_router = child_router.route(path, handler.clone()); + } + RequestMethod::PATCH => { + child_router = child_router.route(path, handler.clone()); + } + } + } + + root_router_child = + root_router_child.merge(Router::new().nest(prefix, child_router)); + } + + Router::new().nest(self.prefix.as_ref(), root_router_child) + } + } + + impl RouteGroup { + pub fn new(prefix: &'static str) -> Self { + Self { + prefix, + routes: vec![], + } + } + + pub fn add_route_item(&mut self, item: RouteItem) { + self.routes.push(item); + } + } + + impl RouteItem { + pub fn new( + path: &'static str, + method: RequestMethod, + handler: MethodRouter, + ) -> Self { + Self { + path, + method, + handler, + } + } + + pub fn get(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::GET, handler) + } + + pub fn post(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::POST, handler) + } + + pub fn put(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::PUT, handler) + } + + pub fn delete(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::DELETE, handler) + } + + pub fn patch(path: &'static str, handler: MethodRouter) -> Self { + Self::new(path, RequestMethod::PATCH, handler) + } + } +} diff --git a/server/src/routes/accounts.rs b/server/src/routes/accounts.rs new file mode 100644 index 00000000..f503be0a --- /dev/null +++ b/server/src/routes/accounts.rs @@ -0,0 +1,37 @@ +use axum::routing::post; + +use crate::handlers::{ + batch_delete_accounts_handler, batch_import_accounts_handler, create_account_handler, + delete_account_handler, get_account_handler, get_accounts_handler, update_account_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册账号相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut account_route = route::RouteGroup::new("/accounts"); + + account_route.add_route_item(route::RouteItem::post("/list", post(get_accounts_handler))); + account_route.add_route_item(route::RouteItem::post("/detail", post(get_account_handler))); + account_route.add_route_item(route::RouteItem::post( + "/create", + post(create_account_handler), + )); + account_route.add_route_item(route::RouteItem::post( + "/update", + post(update_account_handler), + )); + account_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_account_handler), + )); + account_route.add_route_item(route::RouteItem::post( + "/batch-delete", + post(batch_delete_accounts_handler), + )); + account_route.add_route_item(route::RouteItem::post( + "/batch-import", + post(batch_import_accounts_handler), + )); + + meta_route.add_route_group(account_route); +} diff --git a/server/src/routes/audit.rs b/server/src/routes/audit.rs new file mode 100644 index 00000000..4cd5fdef --- /dev/null +++ b/server/src/routes/audit.rs @@ -0,0 +1,31 @@ +use axum::routing::post; + +use crate::handlers::{ + export_audit_logs_handler, get_audit_log_detail_handler, get_audit_logs_handler, + get_audit_stats_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册审计日志相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut audit_route = route::RouteGroup::new("/audit"); + + audit_route.add_route_item(route::RouteItem::post( + "/logs", + post(get_audit_logs_handler), + )); + audit_route.add_route_item(route::RouteItem::post( + "/logs/detail", + post(get_audit_log_detail_handler), + )); + audit_route.add_route_item(route::RouteItem::post( + "/logs/export", + post(export_audit_logs_handler), + )); + audit_route.add_route_item(route::RouteItem::post( + "/stats", + post(get_audit_stats_handler), + )); + + meta_route.add_route_group(audit_route); +} diff --git a/server/src/routes/billing.rs b/server/src/routes/billing.rs new file mode 100644 index 00000000..226a6e17 --- /dev/null +++ b/server/src/routes/billing.rs @@ -0,0 +1,108 @@ +use axum::routing::post; + +use crate::handlers::{ + cancel_subscription_handler, create_recharge_order_handler, get_account_info_handler, + get_auto_renewal_services_handler, get_available_coupons_handler, + get_current_subscription_handler, get_invoices_handler, get_order_status_handler, + get_payment_orders_handler, get_plan_detail_handler, get_plan_price_handler, + get_plans_handler, get_quota_handler, get_transactions_handler, get_user_coupons_handler, + get_wallet_handler, resume_subscription_handler, subscribe_plan_handler, + toggle_auto_renew_handler, verify_coupon_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册计费相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut billing_route = route::RouteGroup::new("/billing"); + + // 套餐 + billing_route.add_route_item(route::RouteItem::post("/plans", post(get_plans_handler))); + billing_route.add_route_item(route::RouteItem::post( + "/plans/detail", + post(get_plan_detail_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/plans/price", + post(get_plan_price_handler), + )); + + // 订阅 + billing_route.add_route_item(route::RouteItem::post( + "/subscription", + post(get_current_subscription_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/subscription/subscribe", + post(subscribe_plan_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/subscription/cancel", + post(cancel_subscription_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/subscription/resume", + post(resume_subscription_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/subscription/toggle-auto-renew", + post(toggle_auto_renew_handler), + )); + + // 钱包 + billing_route.add_route_item(route::RouteItem::post("/wallet", post(get_wallet_handler))); + billing_route.add_route_item(route::RouteItem::post( + "/wallet/transactions", + post(get_transactions_handler), + )); + + // 发票 + billing_route.add_route_item(route::RouteItem::post( + "/invoices", + post(get_invoices_handler), + )); + + // 配额 + billing_route.add_route_item(route::RouteItem::post("/quota", post(get_quota_handler))); + + // 优惠券 + billing_route.add_route_item(route::RouteItem::post( + "/coupon/verify", + post(verify_coupon_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/coupons/my-coupons", + post(get_user_coupons_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/coupons/available", + post(get_available_coupons_handler), + )); + + // 支付订单 + billing_route.add_route_item(route::RouteItem::post( + "/orders", + post(get_payment_orders_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/orders/create-recharge", + post(create_recharge_order_handler), + )); + billing_route.add_route_item(route::RouteItem::post( + "/orders/status", + post(get_order_status_handler), + )); + + // 自动续费服务 + billing_route.add_route_item(route::RouteItem::post( + "/auto-renewal-services", + post(get_auto_renewal_services_handler), + )); + + // 账户信息 + billing_route.add_route_item(route::RouteItem::post( + "/account-info", + post(get_account_info_handler), + )); + + meta_route.add_route_group(billing_route); +} diff --git a/server/src/routes/browser_kernel.rs b/server/src/routes/browser_kernel.rs new file mode 100644 index 00000000..38536ee6 --- /dev/null +++ b/server/src/routes/browser_kernel.rs @@ -0,0 +1,16 @@ +use axum::routing::post; + +use crate::handlers::browser_kernel::list_browser_kernels_handler; +use crate::routes::route::{self, MetaRoute}; + +/// 注册浏览器内核相关路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut browser_kernel_route = route::RouteGroup::new("/browser-kernels"); + + browser_kernel_route.add_route_item(route::RouteItem::post( + "/list", + post(list_browser_kernels_handler), + )); + + meta_route.add_route_group(browser_kernel_route); +} diff --git a/server/src/routes/environments.rs b/server/src/routes/environments.rs new file mode 100644 index 00000000..45c5e4cb --- /dev/null +++ b/server/src/routes/environments.rs @@ -0,0 +1,172 @@ +use axum::routing::post; + +use crate::handlers::{ + add_environment_cookie_handler, add_environment_url_handler, assign_tags_handler, + batch_assign_tags_handler, batch_create_environments_handler, + batch_delete_environments_handler, batch_get_environments_handler, + batch_move_to_group_handler, batch_permanent_delete_environments_handler, + batch_remove_tags_handler, batch_restore_environments_handler, + clear_environment_cookies_handler, clear_environment_urls_handler, + create_environment_handler, create_group_handler, create_tag_handler, + delete_environment_cookie_handler, delete_environment_handler, + delete_environment_url_handler, delete_group_handler, delete_tag_handler, + get_environment_cookies_handler, get_environment_handler, get_environment_urls_handler, + get_environments_handler, get_groups_handler, get_recycle_bin_environments_handler, + get_tags_handler, move_to_group_handler, permanent_delete_environment_handler, + remove_tag_handler, restore_environment_handler, set_environment_accounts_handler, + set_proxy_handler, update_environment_handler, update_group_handler, update_tag_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册环境相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + // 分组路由 + let mut group_route = route::RouteGroup::new("/groups"); + group_route.add_route_item(route::RouteItem::post("/list", post(get_groups_handler))); + group_route.add_route_item(route::RouteItem::post( + "/create", + post(create_group_handler), + )); + group_route.add_route_item(route::RouteItem::post( + "/update", + post(update_group_handler), + )); + group_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_group_handler), + )); + meta_route.add_route_group(group_route); + + // 标签路由 + let mut tag_route = route::RouteGroup::new("/tags"); + tag_route.add_route_item(route::RouteItem::post("/list", post(get_tags_handler))); + tag_route.add_route_item(route::RouteItem::post("/create", post(create_tag_handler))); + tag_route.add_route_item(route::RouteItem::post("/update", post(update_tag_handler))); + tag_route.add_route_item(route::RouteItem::post("/delete", post(delete_tag_handler))); + meta_route.add_route_group(tag_route); + + // 环境路由 + let mut env_route = route::RouteGroup::new("/environments"); + env_route.add_route_item(route::RouteItem::post( + "/list", + post(get_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/detail", + post(get_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-detail", + post(batch_get_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/create", + post(create_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-create", + post(batch_create_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/update", + post(update_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-delete", + post(batch_delete_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/set-proxy", + post(set_proxy_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/assign-tags", + post(assign_tags_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/remove-tag", + post(remove_tag_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/move-to-group", + post(move_to_group_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-move-to-group", + post(batch_move_to_group_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/set-accounts", + post(set_environment_accounts_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-assign-tags", + post(batch_assign_tags_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/batch-remove-tags", + post(batch_remove_tags_handler), + )); + // URL 管理 + env_route.add_route_item(route::RouteItem::post( + "/urls/list", + post(get_environment_urls_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/urls/add", + post(add_environment_url_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/urls/delete", + post(delete_environment_url_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/urls/clear", + post(clear_environment_urls_handler), + )); + // Cookie 管理 + env_route.add_route_item(route::RouteItem::post( + "/cookies/list", + post(get_environment_cookies_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/cookies/add", + post(add_environment_cookie_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/cookies/delete", + post(delete_environment_cookie_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/cookies/clear", + post(clear_environment_cookies_handler), + )); + + // 回收站路由(作为环境路由的子路由) + env_route.add_route_item(route::RouteItem::post( + "/recycle-bin/list", + post(get_recycle_bin_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/recycle-bin/restore", + post(restore_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/recycle-bin/batch-restore", + post(batch_restore_environments_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/recycle-bin/permanent-delete", + post(permanent_delete_environment_handler), + )); + env_route.add_route_item(route::RouteItem::post( + "/recycle-bin/batch-permanent-delete", + post(batch_permanent_delete_environments_handler), + )); + + meta_route.add_route_group(env_route); +} diff --git a/server/src/routes/extensions.rs b/server/src/routes/extensions.rs new file mode 100644 index 00000000..328a0e4e --- /dev/null +++ b/server/src/routes/extensions.rs @@ -0,0 +1,64 @@ +use axum::routing::post; + +use crate::handlers::{ + batch_update_extensions_handler, disable_extension_handler, enable_extension_handler, + get_extension_categories_handler, get_extension_detail_handler, get_extensions_handler, + get_installed_extensions_handler, install_extension_handler, uninstall_extension_handler, + update_extension_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册扩展管理相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut ext_route = route::RouteGroup::new("/extensions"); + + // 扩展市场 + ext_route.add_route_item(route::RouteItem::post( + "/list", + post(get_extensions_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/detail", + post(get_extension_detail_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/categories", + post(get_extension_categories_handler), + )); + + // 已安装扩展 + ext_route.add_route_item(route::RouteItem::post( + "/installed", + post(get_installed_extensions_handler), + )); + + // 安装/卸载/更新 + ext_route.add_route_item(route::RouteItem::post( + "/install", + post(install_extension_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/uninstall", + post(uninstall_extension_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/update", + post(update_extension_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/batch-update", + post(batch_update_extensions_handler), + )); + + // 禁用/启用扩展 + ext_route.add_route_item(route::RouteItem::post( + "/disable", + post(disable_extension_handler), + )); + ext_route.add_route_item(route::RouteItem::post( + "/enable", + post(enable_extension_handler), + )); + + meta_route.add_route_group(ext_route); +} diff --git a/server/src/routes/group_permissions.rs b/server/src/routes/group_permissions.rs new file mode 100644 index 00000000..ea7b652a --- /dev/null +++ b/server/src/routes/group_permissions.rs @@ -0,0 +1,28 @@ +use axum::routing::post; + +use crate::handlers::group_permissions; +use crate::routes::route::{self, MetaRoute}; + +/// 注册分组权限相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut group_permission_route = route::RouteGroup::new("/group-permissions"); + + group_permission_route.add_route_item(route::RouteItem::post( + "/grant", + post(group_permissions::grant_group_permission_handler), + )); + group_permission_route.add_route_item(route::RouteItem::post( + "/revoke", + post(group_permissions::revoke_group_permission_handler), + )); + group_permission_route.add_route_item(route::RouteItem::post( + "/check", + post(group_permissions::check_group_permission_handler), + )); + group_permission_route.add_route_item(route::RouteItem::post( + "/list", + post(group_permissions::list_user_group_permissions_handler), + )); + + meta_route.add_route_group(group_permission_route); +} diff --git a/server/src/routes/health.rs b/server/src/routes/health.rs new file mode 100644 index 00000000..54507a4b --- /dev/null +++ b/server/src/routes/health.rs @@ -0,0 +1,13 @@ +use axum::routing::get; + +use crate::handlers::health_check_handler; +use crate::routes::route::{self, MetaRoute}; + +/// 注册健康检查路由 +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut health_route = route::RouteGroup::new("/health"); + + health_route.add_route_item(route::RouteItem::get("/", get(health_check_handler))); + + meta_route.add_route_group(health_route); +} diff --git a/server/src/routes/local_api.rs b/server/src/routes/local_api.rs new file mode 100644 index 00000000..ffef4e82 --- /dev/null +++ b/server/src/routes/local_api.rs @@ -0,0 +1,27 @@ +use axum::routing::post; + +use crate::handlers::local_api; +use crate::routes::route::{self, MetaRoute}; + +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut local_api_route = route::RouteGroup::new("/local-api"); + + local_api_route.add_route_item(route::RouteItem::post( + "/get", + post(local_api::get_local_api_config_handler), + )); + local_api_route.add_route_item(route::RouteItem::post( + "/update", + post(local_api::update_local_api_config_handler), + )); + local_api_route.add_route_item(route::RouteItem::post( + "/reset-api-key", + post(local_api::reset_local_api_key_handler), + )); + local_api_route.add_route_item(route::RouteItem::post( + "/validate", + post(local_api::validate_local_api_key_handler), + )); + + meta_route.add_route_group(local_api_route); +} diff --git a/server/src/routes/messages.rs b/server/src/routes/messages.rs new file mode 100644 index 00000000..09d0d4ee --- /dev/null +++ b/server/src/routes/messages.rs @@ -0,0 +1,45 @@ +use axum::routing::post; + +use crate::handlers::{ + batch_mark_messages_read_handler, create_message_handler, delete_message_handler, + get_user_message_stats_handler, get_user_messages_handler, handle_message_handler, + mark_message_read_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册消息相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut message_route = route::RouteGroup::new("/messages"); + + // 消息管理 + message_route.add_route_item(route::RouteItem::post( + "/create", + post(create_message_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/list", + post(get_user_messages_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/stats", + post(get_user_message_stats_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/read", + post(mark_message_read_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/batch-read", + post(batch_mark_messages_read_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/handle", + post(handle_message_handler), + )); + message_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_message_handler), + )); + + meta_route.add_route_group(message_route); +} diff --git a/server/src/routes/preferences.rs b/server/src/routes/preferences.rs new file mode 100644 index 00000000..a60f044e --- /dev/null +++ b/server/src/routes/preferences.rs @@ -0,0 +1,20 @@ +use axum::routing::post; + +use crate::handlers::{get_preferences_handler, update_preferences_handler}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册用户偏好设置相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut pref_route = route::RouteGroup::new("/preferences"); + + pref_route.add_route_item(route::RouteItem::post( + "/get", + post(get_preferences_handler), + )); + pref_route.add_route_item(route::RouteItem::post( + "/update", + post(update_preferences_handler), + )); + + meta_route.add_route_group(pref_route); +} diff --git a/server/src/routes/proxies.rs b/server/src/routes/proxies.rs new file mode 100644 index 00000000..3d63c6d2 --- /dev/null +++ b/server/src/routes/proxies.rs @@ -0,0 +1,37 @@ +use axum::routing::post; + +use crate::handlers::{ + batch_delete_proxies_handler, batch_import_proxies_handler, create_proxy_handler, + delete_proxy_handler, get_proxies_handler, get_proxy_handler, update_proxy_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册代理相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut proxy_route = route::RouteGroup::new("/proxies"); + + proxy_route.add_route_item(route::RouteItem::post("/list", post(get_proxies_handler))); + proxy_route.add_route_item(route::RouteItem::post("/detail", post(get_proxy_handler))); + proxy_route.add_route_item(route::RouteItem::post( + "/create", + post(create_proxy_handler), + )); + proxy_route.add_route_item(route::RouteItem::post( + "/update", + post(update_proxy_handler), + )); + proxy_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_proxy_handler), + )); + proxy_route.add_route_item(route::RouteItem::post( + "/batch-delete", + post(batch_delete_proxies_handler), + )); + proxy_route.add_route_item(route::RouteItem::post( + "/batch-import", + post(batch_import_proxies_handler), + )); + + meta_route.add_route_group(proxy_route); +} diff --git a/server/src/routes/proxy_visibility.rs b/server/src/routes/proxy_visibility.rs new file mode 100644 index 00000000..a4af89cf --- /dev/null +++ b/server/src/routes/proxy_visibility.rs @@ -0,0 +1,32 @@ +use axum::routing::post; + +use crate::handlers::proxy_visibility; +use crate::routes::route::{self, MetaRoute}; + +/// 注册代理可见性相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut proxy_visibility_route = route::RouteGroup::new("/proxy-visibility"); + + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/set", + post(proxy_visibility::set_proxy_visible_handler), + )); + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/remove", + post(proxy_visibility::remove_proxy_visible_handler), + )); + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/batch-set", + post(proxy_visibility::batch_set_proxy_visible_handler), + )); + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/list-visible", + post(proxy_visibility::get_visible_proxies_handler), + )); + proxy_visibility_route.add_route_item(route::RouteItem::post( + "/list-teams", + post(proxy_visibility::get_proxy_visible_teams_handler), + )); + + meta_route.add_route_group(proxy_visibility_route); +} diff --git a/server/src/routes/referral.rs b/server/src/routes/referral.rs new file mode 100644 index 00000000..e16fd3da --- /dev/null +++ b/server/src/routes/referral.rs @@ -0,0 +1,82 @@ +use axum::routing::post; + +use crate::handlers::{ + get_redeem_options_handler, get_redeem_records_handler, get_referral_dashboard_handler, + get_referral_links_handler, get_referral_plan_summary_handler, get_referral_rewards_handler, + get_referral_stats_handler, get_referral_tiers_handler, get_referred_users_handler, + get_user_points_handler, redeem_points_handler, switch_referral_link_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册推荐计划相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut referral_route = route::RouteGroup::new("/referral"); + + // 统计 + referral_route.add_route_item(route::RouteItem::post( + "/stats", + post(get_referral_stats_handler), + )); + + // 统计看板聚合 + referral_route.add_route_item(route::RouteItem::post( + "/dashboard", + post(get_referral_dashboard_handler), + )); + + // 套餐页推广摘要 + referral_route.add_route_item(route::RouteItem::post( + "/summary-for-plan", + post(get_referral_plan_summary_handler), + )); + + // 层级配置 + referral_route.add_route_item(route::RouteItem::post( + "/tiers", + post(get_referral_tiers_handler), + )); + + // 推广链接 + referral_route.add_route_item(route::RouteItem::post( + "/links", + post(get_referral_links_handler), + )); + referral_route.add_route_item(route::RouteItem::post( + "/links/switch", + post(switch_referral_link_handler), + )); + + // 奖励记录 + referral_route.add_route_item(route::RouteItem::post( + "/rewards", + post(get_referral_rewards_handler), + )); + + // 被邀请用户 + referral_route.add_route_item(route::RouteItem::post( + "/users", + post(get_referred_users_handler), + )); + + // 积分 + referral_route.add_route_item(route::RouteItem::post( + "/points", + post(get_user_points_handler), + )); + + // 兑换选项 + referral_route.add_route_item(route::RouteItem::post( + "/redeem/options", + post(get_redeem_options_handler), + )); + referral_route.add_route_item(route::RouteItem::post( + "/redeem", + post(redeem_points_handler), + )); + referral_route.add_route_item(route::RouteItem::post( + "/redeem/records", + post(get_redeem_records_handler), + )); + + meta_route.add_route_group(referral_route); +} diff --git a/server/src/routes/rpa.rs b/server/src/routes/rpa.rs new file mode 100644 index 00000000..4e715ef2 --- /dev/null +++ b/server/src/routes/rpa.rs @@ -0,0 +1,44 @@ +use axum::routing::post; + +use crate::handlers::{ + batch_delete_rpa_tasks_handler, create_rpa_task_handler, delete_rpa_task_handler, + duplicate_rpa_task_handler, export_rpa_task_handler, get_rpa_task_handler, + get_rpa_tasks_handler, update_rpa_task_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut rpa_route = route::RouteGroup::new("/rpa"); + + rpa_route.add_route_item(route::RouteItem::post("/tasks", post(get_rpa_tasks_handler))); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/detail", + post(get_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/create", + post(create_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/update", + post(update_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/delete", + post(delete_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/batch-delete", + post(batch_delete_rpa_tasks_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/duplicate", + post(duplicate_rpa_task_handler), + )); + rpa_route.add_route_item(route::RouteItem::post( + "/tasks/export", + post(export_rpa_task_handler), + )); + + meta_route.add_route_group(rpa_route); +} diff --git a/server/src/routes/secret.rs b/server/src/routes/secret.rs new file mode 100644 index 00000000..7490276a --- /dev/null +++ b/server/src/routes/secret.rs @@ -0,0 +1,16 @@ +use axum::routing::get; + +use crate::handlers::get_public_key_handler; +use crate::routes::route::{self, MetaRoute}; + +/// 注册 secret 相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut secret_route = route::RouteGroup::new("/secret"); + + secret_route.add_route_item(route::RouteItem::get( + "/public/key", + get(get_public_key_handler), + )); + + meta_route.add_route_group(secret_route); +} diff --git a/server/src/routes/teams.rs b/server/src/routes/teams.rs new file mode 100644 index 00000000..354b3d1c --- /dev/null +++ b/server/src/routes/teams.rs @@ -0,0 +1,61 @@ +use axum::routing::post; + +use crate::handlers::{ + accept_invitation_handler, cancel_invitation_handler, create_team_handler, + get_my_teams_handler, get_pending_invitations_handler, get_team_handler, + get_team_members_handler, invite_member_handler, leave_team_handler, reject_invitation_handler, + remove_member_handler, switch_team_handler, update_member_role_handler, update_team_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册团队相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut team_route = route::RouteGroup::new("/teams"); + + // 团队管理 + team_route.add_route_item(route::RouteItem::post("/create", post(create_team_handler))); + team_route.add_route_item(route::RouteItem::post( + "/my-teams", + post(get_my_teams_handler), + )); + team_route.add_route_item(route::RouteItem::post("/switch", post(switch_team_handler))); + team_route.add_route_item(route::RouteItem::post("/detail", post(get_team_handler))); + team_route.add_route_item(route::RouteItem::post("/update", post(update_team_handler))); + team_route.add_route_item(route::RouteItem::post("/leave", post(leave_team_handler))); + + // 成员管理 + team_route.add_route_item(route::RouteItem::post( + "/members", + post(get_team_members_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invite", + post(invite_member_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invitations", + post(get_pending_invitations_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invitation/cancel", + post(cancel_invitation_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invitation/accept", + post(accept_invitation_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/invitation/reject", + post(reject_invitation_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/member/role", + post(update_member_role_handler), + )); + team_route.add_route_item(route::RouteItem::post( + "/member/remove", + post(remove_member_handler), + )); + + meta_route.add_route_group(team_route); +} diff --git a/server/src/routes/templates.rs b/server/src/routes/templates.rs new file mode 100644 index 00000000..c7a72c32 --- /dev/null +++ b/server/src/routes/templates.rs @@ -0,0 +1,40 @@ +use axum::routing::post; + +use crate::handlers::{ + apply_template_handler, create_from_template_handler, create_template_handler, + delete_template_handler, get_template_handler, get_templates_handler, update_template_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册模板相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut template_route = route::RouteGroup::new("/templates"); + + template_route.add_route_item(route::RouteItem::post("/list", post(get_templates_handler))); + template_route.add_route_item(route::RouteItem::post( + "/detail", + post(get_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/create", + post(create_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/update", + post(update_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/delete", + post(delete_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/apply", + post(apply_template_handler), + )); + template_route.add_route_item(route::RouteItem::post( + "/create-from", + post(create_from_template_handler), + )); + + meta_route.add_route_group(template_route); +} diff --git a/server/src/routes/time.rs b/server/src/routes/time.rs new file mode 100644 index 00000000..0e2953a6 --- /dev/null +++ b/server/src/routes/time.rs @@ -0,0 +1,12 @@ +use axum::routing::get; + +use crate::handlers::now_handle; +use crate::routes::route::{self, MetaRoute}; + +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut time_route = route::RouteGroup::new("/time"); + + time_route.add_route_item(route::RouteItem::get("/now", get(now_handle))); + + meta_route.add_route_group(time_route); +} diff --git a/server/src/routes/users.rs b/server/src/routes/users.rs new file mode 100644 index 00000000..61a98c6d --- /dev/null +++ b/server/src/routes/users.rs @@ -0,0 +1,56 @@ +use axum::routing::post; + +use crate::handlers::{ + get_current_user_handler, login_handler, refresh_token_handler, register_handler, + reset_password_handler, send_code_handler, update_password_handler, update_user_handler, + verify_password_handler, +}; +use crate::routes::route::{self, MetaRoute}; + +/// 注册用户相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) -> () { + let mut user_route = route::RouteGroup::new("/users"); + + user_route.add_route_item(route::RouteItem::post("/register", post(register_handler))); + + user_route.add_route_item(route::RouteItem::post("/login", post(login_handler))); + + user_route.add_route_item(route::RouteItem::post( + "/refresh-credentials", + post(refresh_token_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/me", + post(get_current_user_handler), + )); + + user_route.add_route_item(route::RouteItem::post("/update", post(update_user_handler))); + + user_route.add_route_item(route::RouteItem::post( + "/password", + post(update_password_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/verify-password", + post(verify_password_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/reset-password", + post(reset_password_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/register-send-code", + post(send_code_handler), + )); + + user_route.add_route_item(route::RouteItem::post( + "/reset-password-send-code", + post(send_code_handler), + )); + + meta_route.add_route_group(user_route); +} diff --git a/server/src/routes/workspace_quotas.rs b/server/src/routes/workspace_quotas.rs new file mode 100644 index 00000000..e4198dc0 --- /dev/null +++ b/server/src/routes/workspace_quotas.rs @@ -0,0 +1,20 @@ +use axum::routing::post; + +use crate::handlers::workspace_quotas; +use crate::routes::route::{self, MetaRoute}; + +/// 注册工作空间配额相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut quota_route = route::RouteGroup::new("/workspace-quotas"); + + quota_route.add_route_item(route::RouteItem::post( + "/get", + post(workspace_quotas::get_workspace_quota_handler), + )); + quota_route.add_route_item(route::RouteItem::post( + "/update", + post(workspace_quotas::update_quota_usage_handler), + )); + + meta_route.add_route_group(quota_route); +} diff --git a/server/src/routes/workspaces.rs b/server/src/routes/workspaces.rs new file mode 100644 index 00000000..0a9895d4 --- /dev/null +++ b/server/src/routes/workspaces.rs @@ -0,0 +1,36 @@ +use axum::routing::post; + +use crate::handlers::workspaces; +use crate::routes::route::{self, MetaRoute}; + +/// 注册工作空间相关的路由 +pub fn register_routes(meta_route: &mut MetaRoute) { + let mut workspace_route = route::RouteGroup::new("/workspaces"); + + workspace_route.add_route_item(route::RouteItem::post( + "/create", + post(workspaces::create_workspace_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/list", + post(workspaces::get_my_workspaces_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/get", + post(workspaces::get_workspace_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/update", + post(workspaces::update_workspace_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/delete", + post(workspaces::delete_workspace_handler), + )); + workspace_route.add_route_item(route::RouteItem::post( + "/switch", + post(workspaces::switch_workspace_handler), + )); + + meta_route.add_route_group(workspace_route); +} diff --git a/server/src/services.rs b/server/src/services.rs new file mode 100644 index 00000000..acdff50e --- /dev/null +++ b/server/src/services.rs @@ -0,0 +1,62 @@ +mod maintenance; +mod strategy_types; +mod time; +pub mod users; +mod version_types; +mod versions; + +// 新增模块 +pub mod accounts; +pub mod audit; +pub mod billing; +pub mod browser_kernel; +pub mod coupons; +pub mod environments; +pub mod extensions; +pub mod group_permissions; +pub mod groups; +pub mod local_api; +pub mod messages; +pub mod orders; +pub mod plans; +pub mod preferences; +pub mod proxies; +pub mod proxy_visibility; +pub mod referral; +pub mod rpa; +pub mod subscriptions; +pub mod tags; +pub mod teams; +pub mod templates; +pub mod wallet; +pub mod workspace_quotas; +pub mod workspaces; + +pub use maintenance::*; +pub use strategy_types::*; +pub use time::*; +pub use users::*; +pub use version_types::*; +pub use versions::*; + +// 新增导出 +pub use accounts::*; +pub use audit::*; +pub use billing::*; +pub use browser_kernel::*; +pub use coupons::*; +pub use environments::*; +pub use group_permissions::*; +pub use groups::*; +pub use messages::*; +pub use orders::*; +pub use plans::*; +pub use proxies::*; +pub use proxy_visibility::*; +pub use subscriptions::*; +pub use tags::*; +pub use teams::*; +pub use templates::*; +pub use wallet::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/server/src/services/accounts.rs b/server/src/services/accounts.rs new file mode 100644 index 00000000..a55df8f8 --- /dev/null +++ b/server/src/services/accounts.rs @@ -0,0 +1,210 @@ +use uuid::Uuid; + +use crate::dto::PlatformAccountDto; +use crate::entitys::{ + BatchImportAccountsRequest, CreateAccountRequest, ListAccountsRequest, UpdateAccountRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建账号 +pub async fn create_account_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateAccountRequest, +) -> Result { + models::insert_platform_account( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.platform_url, + payload.platform_name.as_deref(), + &payload.account, + payload.password.as_deref(), + payload.remark.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取账号列表 +pub async fn get_accounts_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &ListAccountsRequest, +) -> Result<(Vec, i64), String> { + let page = payload.pagination.page.max(1); + let page_size = payload.pagination.page_size.max(1); + let offset = (page - 1) * page_size; + + let keyword = payload + .filters + .as_ref() + .and_then(|f| f.keyword.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let platform_name = payload + .filters + .as_ref() + .and_then(|f| f.platform_name.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let status = payload + .filters + .as_ref() + .and_then(|f| f.status.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + + let accounts = models::fetch_platform_accounts( + &svc_ctx.db, + team_uuid, + user_uuid, + keyword, + platform_name, + status, + offset, + page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_platform_accounts_count( + &svc_ctx.db, + team_uuid, + user_uuid, + keyword, + platform_name, + status, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((accounts, total)) +} + +/// 获取账号详情 +pub async fn get_account_service( + svc_ctx: &SvcCtx, + account_uuid: Uuid, +) -> Result { + models::fetch_platform_account_by_uuid(&svc_ctx.db, account_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "账号不存在".to_string()) +} + +/// 更新账号 +pub async fn update_account_service( + svc_ctx: &SvcCtx, + payload: &UpdateAccountRequest, +) -> Result<(), String> { + models::update_platform_account( + &svc_ctx.db, + payload.uuid, + payload.platform_url.as_deref(), + payload.platform_name.as_deref(), + payload.account.as_deref(), + payload.password.as_deref(), + payload.remark.as_deref(), + payload.status.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除账号 +pub async fn delete_account_service(svc_ctx: &SvcCtx, account_uuid: Uuid) -> Result<(), String> { + models::delete_platform_account(&svc_ctx.db, account_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量删除账号 +pub async fn batch_delete_accounts_service( + svc_ctx: &SvcCtx, + account_uuids: &[Uuid], +) -> Result { + models::batch_delete_platform_accounts(&svc_ctx.db, account_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境关联的账号 +pub async fn get_environment_accounts_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_accounts(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 设置环境关联的账号 +pub async fn set_environment_accounts_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + account_uuids: &[Uuid], +) -> Result<(), String> { + // 清空现有关联 + models::accounts::clear_environment_accounts(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + // 添加新关联 + for (idx, account_uuid) in account_uuids.iter().enumerate() { + models::accounts::insert_environment_account( + &svc_ctx.db, + env_uuid, + *account_uuid, + idx as i32, + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +/// 批量导入账号 +/// +/// 接收客户端已解析好的账号列表,直接保存到数据库 +pub async fn batch_import_accounts_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &BatchImportAccountsRequest, +) -> Result { + let mut success_count = 0; + let mut failed_count = 0; + let mut errors: Vec = vec![]; + for (index, account) in payload.accounts.iter().enumerate() { + let result = models::insert_platform_account( + &svc_ctx.db, + user_uuid, + team_uuid, + &account.platform_url, + account.platform_name.as_deref(), + &account.account, + account.password.as_deref(), + account.remark.as_deref(), + ) + .await; + + match result { + Ok(_) => success_count += 1, + Err(e) => { + failed_count += 1; + errors.push(format!("第 {} 项: {}", index + 1, e)); + } + } + } + + Ok(crate::entitys::BatchImportResponse { + success_count, + failed_count, + errors, + }) +} diff --git a/server/src/services/audit.rs b/server/src/services/audit.rs new file mode 100644 index 00000000..42848292 --- /dev/null +++ b/server/src/services/audit.rs @@ -0,0 +1,352 @@ +use chrono::Datelike; +use uuid::Uuid; + +use crate::dto::AuditLogDto; +use crate::entitys::{ + ActionCount, AuditStatsResponse, ExportAuditLogsRequest, ListAuditLogsRequest, TargetTypeCount, +}; +use crate::models; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; + +/// 审计日志宏 - 简化审计日志记录 +/// +/// # 用法 +/// ```rust +/// // 基础用法: action, target_type, detail +/// audit_log!(svc_ctx, ctx, "login", "user", "用户登录"); +/// +/// // 带目标 UUID +/// audit_log!(svc_ctx, ctx, "delete", "environment", env_uuid, "删除环境"); +/// +/// // 带目标 UUID 和名称 +/// audit_log!(svc_ctx, ctx, "delete", "environment", env_uuid, "生产环境", "删除环境"); +/// ``` +#[macro_export] +macro_rules! audit_log { + // 基础: action, target_type, detail + ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $detail:expr) => { + $crate::services::audit::log_audit( + $svc_ctx, + $ctx, + $action, + $target_type, + None, + None, + $detail, + ) + }; + // 带目标 UUID: action, target_type, target_uuid, detail + ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $target_uuid:expr, $detail:expr) => { + $crate::services::audit::log_audit( + $svc_ctx, + $ctx, + $action, + $target_type, + Some($target_uuid), + None, + $detail, + ) + }; + // 完整: action, target_type, target_uuid, target_name, detail + ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $target_uuid:expr, $target_name:expr, $detail:expr) => { + $crate::services::audit::log_audit( + $svc_ctx, + $ctx, + $action, + $target_type, + Some($target_uuid), + Some($target_name), + $detail, + ) + }; +} + +/// 记录审计日志(宏的内部实现) +/// +/// 自动从 RequestContext 中提取 user_uuid、team_uuid、ip_address +pub async fn log_audit( + svc_ctx: &SvcCtx, + ctx: &RequestContext, + action: &str, + target_type: &str, + target_uuid: Option, + target_name: Option<&str>, + detail: &str, +) { + // 从 ctx 中提取数据 + let user_uuid = match ctx.user_uuid() { + Some(uuid) => uuid, + None => { + tracing::warn!("audit_log: user_uuid is None, skipping audit log"); + return; + } + }; + + let team_uuid = ctx.current_team_uuid; + let ip_address = ctx.ip(); + + // 异步记录,忽略错误(审计失败不应影响业务) + if let Err(e) = log_action_service( + svc_ctx, + user_uuid, + team_uuid, + action, + target_type, + target_uuid, + target_name, + Some(detail), + None, // changes + ip_address, + None, // user_agent + None, // request_id + ) + .await + { + tracing::error!("audit_log failed: {}", e); + } +} + +/// 记录审计日志(允许无用户,用于登录/注册等场景) +pub async fn log_audit_anonymous( + svc_ctx: &SvcCtx, + ctx: &RequestContext, + user_uuid: Uuid, + action: &str, + target_type: &str, + detail: &str, +) { + let ip_address = ctx.ip(); + + if let Err(e) = log_action_service( + svc_ctx, + user_uuid, + None, + action, + target_type, + Some(user_uuid), + None, + Some(detail), + None, + ip_address, + None, + None, + ) + .await + { + tracing::error!("audit_log_anonymous failed: {}", e); + } +} + +/// 记录审计日志 +pub async fn log_action_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + action: &str, + target_type: &str, + target_uuid: Option, + target_name: Option<&str>, + details: Option<&str>, + changes: Option<&serde_json::Value>, + ip_address: Option<&str>, + user_agent: Option<&str>, + request_id: Option<&str>, +) -> Result { + models::insert_audit_log( + &svc_ctx.db, + user_uuid, + team_uuid, + action, + target_type, + target_uuid, + target_name, + details, + changes, + ip_address, + user_agent, + request_id, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取审计日志列表 +pub async fn get_audit_logs_service( + svc_ctx: &SvcCtx, + current_user_uuid: Uuid, + team_uuid: Option, + payload: &ListAuditLogsRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let user_uuid_filter = payload.filters.as_ref().and_then(|f| f.user_uuid); + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let action = payload.filters.as_ref().and_then(|f| f.action.as_deref()); + let target_type = payload.filters.as_ref().and_then(|f| f.target_type.as_deref()); + + let logs = models::fetch_audit_logs( + &svc_ctx.db, + current_user_uuid, + team_uuid, + user_uuid_filter, + keyword, + action, + target_type, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_audit_logs_count( + &svc_ctx.db, + current_user_uuid, + team_uuid, + user_uuid_filter, + keyword, + action, + target_type, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((logs, total)) +} + +/// 获取审计日志详情 +pub async fn get_audit_log_service( + svc_ctx: &SvcCtx, + log_uuid: Uuid, +) -> Result { + models::fetch_audit_log_by_uuid(&svc_ctx.db, log_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "审计日志不存在".to_string()) +} + +/// 获取审计统计 +pub async fn get_audit_stats_service( + svc_ctx: &SvcCtx, + current_user_uuid: Uuid, + team_uuid: Option, +) -> Result { + // 总数 + let total_logs = + models::fetch_audit_logs_count( + &svc_ctx.db, + current_user_uuid, + team_uuid, + None, + None, + None, + None, + ) + .await + .map_err(|e| e.to_string())?; + + // 今日数量 + let today = chrono::Utc::now().date_naive(); + let logs_today = models::fetch_audit_logs_count_by_date(&svc_ctx.db, team_uuid, today) + .await + .map_err(|e| e.to_string())?; + + // 本周数量 + let week_start = today - chrono::Duration::days(today.weekday().num_days_from_monday() as i64); + let logs_this_week = + models::fetch_audit_logs_count_since_date(&svc_ctx.db, team_uuid, week_start) + .await + .map_err(|e| e.to_string())?; + + // 本月数量 + let month_start = chrono::NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap(); + let logs_this_month = + models::fetch_audit_logs_count_since_date(&svc_ctx.db, team_uuid, month_start) + .await + .map_err(|e| e.to_string())?; + + // 热门操作(Top 5) + let top_actions_raw = models::fetch_top_actions(&svc_ctx.db, team_uuid, 5) + .await + .map_err(|e| e.to_string())?; + + // 热门目标类型(Top 5) + let top_target_types_raw = models::fetch_top_target_types(&svc_ctx.db, team_uuid, 5) + .await + .map_err(|e| e.to_string())?; + + Ok(AuditStatsResponse { + total_logs, + logs_today, + logs_this_week, + logs_this_month, + top_actions: top_actions_raw + .into_iter() + .map(|(action, count)| ActionCount { action, count }) + .collect(), + top_target_types: top_target_types_raw + .into_iter() + .map(|(target_type, count)| TargetTypeCount { target_type, count }) + .collect(), + }) +} + +/// 导出审计日志 +pub async fn export_audit_logs_service( + svc_ctx: &SvcCtx, + current_user_uuid: Uuid, + team_uuid: Option, + payload: &ExportAuditLogsRequest, +) -> Result<(String, String, String), String> { + // 构建查询请求 + let list_request = ListAuditLogsRequest { + pagination: crate::entitys::Pagination { + page: 1, + page_size: payload.max_records.unwrap_or(1000) as i64, + sort_by: None, + sort_order: None, + }, + filters: payload.filters.clone(), + }; + + let (logs, _total) = + get_audit_logs_service(svc_ctx, current_user_uuid, team_uuid, &list_request).await?; + + // 根据格式生成导出内容 + let content = match payload.format.as_str() { + "csv" => export_to_csv(&logs), + "json" => serde_json::to_string_pretty(&logs).unwrap_or_default(), + _ => return Err("不支持的导出格式".to_string()), + }; + + let filename = format!( + "audit_logs_{}.{}", + chrono::Utc::now().format("%Y%m%d%H%M%S"), + payload.format + ); + + let mime_type = match payload.format.as_str() { + "csv" => "text/csv".to_string(), + "json" => "application/json".to_string(), + _ => "text/plain".to_string(), + }; + + Ok((content, filename, mime_type)) +} + +fn export_to_csv(logs: &[AuditLogDto]) -> String { + let mut csv = String::from("时间,用户UUID,操作,目标类型,目标名称,详情,IP地址\n"); + for log in logs { + csv.push_str(&format!( + "{},{},{},{},{},{},{}\n", + log.created_at.format("%Y-%m-%d %H:%M:%S"), + log.user_uuid, + log.action, + log.target_type, + log.target_name.as_deref().unwrap_or(""), + log.details.as_deref().unwrap_or("").replace(',', ";"), + log.ip_address.as_deref().unwrap_or(""), + )); + } + csv +} diff --git a/server/src/services/billing.rs b/server/src/services/billing.rs new file mode 100644 index 00000000..cccf4cb0 --- /dev/null +++ b/server/src/services/billing.rs @@ -0,0 +1,122 @@ +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::dto::{AutoRenewalServiceDto, InvoiceDto, UserQuotaDto}; +use crate::entitys::{AccountInfoResponse, ListInvoicesRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +// ============ Invoices ============ + +/// 获取发票列表 +pub async fn get_invoices_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListInvoicesRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let invoices = models::billing::fetch_invoices( + &svc_ctx.db, + user_uuid, + payload.status.as_deref(), + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = + models::billing::fetch_invoices_count(&svc_ctx.db, user_uuid, payload.status.as_deref()) + .await + .map_err(|e| e.to_string())?; + + Ok((invoices, total)) +} + +// ============ Quotas ============ + +/// 获取用户配额 +pub async fn get_quota_service(svc_ctx: &SvcCtx, user_uuid: Uuid) -> Result { + models::billing::fetch_user_quota(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "用户配额不存在".to_string()) +} + +// ============ Auto Renewal Services ============ + +/// 获取自动续费服务列表 +pub async fn get_auto_renewal_services_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::billing::fetch_auto_renewal_services(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取账户信息(聚合钱包、配额、订阅、用户信息) +pub async fn get_account_info_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result { + // 1. 获取用户信息(邮箱等) + let user_info = models::user::fetch_user_info_by_uuid(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "用户不存在".to_string())?; + + // 2. 获取钱包信息 + let wallet = models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果钱包不存在,创建一个 + let wallet = if wallet.is_none() { + models::billing::insert_user_wallet(&svc_ctx.db, user_uuid, "CNY") + .await + .map_err(|e| e.to_string())?; + + models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "创建钱包失败".to_string())? + } else { + wallet.ok_or_else(|| "钱包不存在".to_string())? + }; + + // 3. 获取工作空间配额 + let quota = models::fetch_workspace_quota(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间配额不存在".to_string())?; + + // 4. 获取工作空间订阅 + let subscription = models::billing::fetch_workspace_active_subscription(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())?; + + // 5. 计算月结费用(订阅价格) + let monthly_billing = subscription + .as_ref() + .map(|s| { + if s.billing_period == "yearly" { + s.price / Decimal::from(12) + } else { + s.price + } + }) + .unwrap_or(Decimal::ZERO); + + Ok(AccountInfoResponse { + email: user_info.email, + wallet_balance: wallet.balance, + gift_balance: Decimal::ZERO, // TODO: 如果有赠送金字段,从钱包获取 + currency: wallet.currency, + subscription, + quota, + monthly_billing, + }) +} diff --git a/server/src/services/browser_kernel.rs b/server/src/services/browser_kernel.rs new file mode 100644 index 00000000..f1be2446 --- /dev/null +++ b/server/src/services/browser_kernel.rs @@ -0,0 +1,41 @@ +use crate::{ + dto::versions::Version, + errors::SimprintError, + svc_ctx::SvcCtx, + utils::get_objects::get_version_resource_url, +}; +use std::collections::HashMap; + +/// 查询浏览器内核最新版本列表 +/// 筛选 SIMPRINT_KERNEL_* 类型(或指定 type_code),按 resource_name 分组取最新 +/// 返回 HashMap>,已填充可下载 URL +pub async fn get_browser_kernel_list_service( + svc_ctx: &SvcCtx, + platform: Option, + type_code: Option, +) -> Result>, SimprintError> { + let results = crate::models::versions::query_browser_kernel_latest_versions( + &svc_ctx.db, + platform.as_deref(), + type_code.as_deref(), + ) + .await + .map_err(|_| SimprintError::Other("查询浏览器内核列表失败".to_string()))?; + + let mut map: HashMap> = HashMap::new(); + + let storage_config = &svc_ctx.config.storage; + + for (type_code, _resource_name, version) in results { + map.entry(type_code).or_insert_with(Vec::new).push(Version { + url: Some(get_version_resource_url( + &storage_config.public_base_url, + &storage_config.version_root, + &version.url.unwrap_or_default(), + )), + ..version + }); + } + + Ok(map) +} diff --git a/server/src/services/coupons.rs b/server/src/services/coupons.rs new file mode 100644 index 00000000..8c431014 --- /dev/null +++ b/server/src/services/coupons.rs @@ -0,0 +1,171 @@ +use chrono::Utc; +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::entitys::{ + BatchIssueCouponRequest, CouponValidationResult, GetUserCouponsRequest, IssueCouponRequest, + UserCouponsListResponse, VerifyCouponRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 验证优惠券 +pub async fn validate_coupon_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &VerifyCouponRequest, +) -> Result { + let coupon = models::billing::fetch_coupon_by_code(&svc_ctx.db, &payload.code) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "优惠券不存在".to_string())?; + + // 检查有效期 + let now = Utc::now(); + if coupon.valid_from > now { + return Err("优惠券尚未生效".to_string()); + } + if let Some(valid_until) = coupon.valid_until { + if valid_until < now { + return Err("优惠券已过期".to_string()); + } + } + + // 检查使用次数限制 + if let Some(max_uses) = coupon.max_uses { + if coupon.used_count >= max_uses { + return Err("优惠券已达使用上限".to_string()); + } + } + + // 检查单用户使用次数限制 + if let Some(max_uses_per_user) = coupon.max_uses_per_user { + let user_usage = + models::billing::fetch_coupon_user_usage_count(&svc_ctx.db, coupon.uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + if user_usage >= max_uses_per_user { + return Err("您已达该优惠券使用上限".to_string()); + } + } + + // 检查最低消费 + if let Some(min_amount) = coupon.min_amount { + if payload.amount < min_amount { + return Err(format!("订单金额需满 {} 元才可使用此优惠券", min_amount)); + } + } + + // 计算折扣金额 + let mut discount_amount = match coupon.discount_type.as_str() { + "percentage" => payload.amount * coupon.discount_value / Decimal::from(100), + "fixed" => coupon.discount_value, + _ => Decimal::ZERO, + }; + + // 检查最大折扣限制 + if let Some(max_discount) = coupon.max_discount { + if discount_amount > max_discount { + discount_amount = max_discount; + } + } + + Ok(CouponValidationResult { + coupon, + discount_amount, + }) +} + +/// 获取用户优惠券列表 +pub async fn get_user_coupons_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &GetUserCouponsRequest, +) -> Result { + let (items, total) = models::billing::fetch_user_coupons( + &svc_ctx.db, + user_uuid, + payload.status.as_deref(), + payload.pagination.page, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(UserCouponsListResponse { + items, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }) +} + +/// 给单个用户发放优惠券 +pub async fn issue_coupon_to_user_service( + svc_ctx: &SvcCtx, + payload: &IssueCouponRequest, +) -> Result { + // 验证优惠券存在且有效 + let coupon = models::billing::fetch_coupon_by_uuid(&svc_ctx.db, payload.coupon_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "优惠券不存在或已失效".to_string())?; + + // 如果提供了过期时间,使用它;否则从优惠券继承 + let expires_at = payload.expires_at.or(coupon.valid_until); + + let id = models::billing::insert_user_coupon( + &svc_ctx.db, + payload.user_uuid, + payload.coupon_uuid, + expires_at, + ) + .await + .map_err(|e| { + if e.to_string().contains("RowNotFound") { + "该用户已拥有此优惠券".to_string() + } else { + e.to_string() + } + })?; + + Ok(id) +} + +/// 批量发放优惠券 +pub async fn batch_issue_coupons_service( + svc_ctx: &SvcCtx, + payload: &BatchIssueCouponRequest, +) -> Result { + // 验证优惠券存在且有效 + let coupon = models::billing::fetch_coupon_by_uuid(&svc_ctx.db, payload.coupon_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "优惠券不存在或已失效".to_string())?; + + // 如果提供了过期时间,使用它;否则从优惠券继承 + let expires_at = payload.expires_at.or(coupon.valid_until); + + let count = models::billing::batch_insert_user_coupons( + &svc_ctx.db, + payload.coupon_uuid, + &payload.user_uuids, + expires_at, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(count) +} + +/// 获取用户可用优惠券(自动过滤过期和已使用的) +pub async fn get_available_coupons_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + let items = models::billing::fetch_available_user_coupons(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok(items) +} diff --git a/server/src/services/environments.rs b/server/src/services/environments.rs new file mode 100644 index 00000000..cc95b0f8 --- /dev/null +++ b/server/src/services/environments.rs @@ -0,0 +1,1621 @@ +use std::collections::HashMap; +use url::Url; +use uuid::Uuid; + +use crate::dto::{ + EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentCookieGroupDto, EnvironmentDto, + EnvironmentUrlDto, GroupSummaryDto, PlatformAccountDto, ProxySummaryDto, TagDto, +}; +use crate::entitys::{ + AddEnvironmentCookieRequest, AddEnvironmentUrlRequest, AssignTagsRequest, + BatchAssignTagRequest, BatchCreateEnvironmentRequest, BatchMoveToGroupRequest, + BatchRemoveTagsRequest, CookieGroupInput, CookieInput, CreateEnvironmentRequest, + ListEnvironmentsRequest, MoveToGroupRequest, SetEnvironmentProxyRequest, + UpdateEnvironmentRequest, UrlInput, +}; +use crate::models; +use crate::services::accounts; +use crate::svc_ctx::SvcCtx; + +// ============ Environments ============ + +/// 创建环境 +pub async fn create_environment_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &CreateEnvironmentRequest, +) -> Result { + // 1. 检查用户是否在当前工作空间的团队中(工作空间级别) + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 权限检查 + if let Some(group_uuid) = payload.group_uuid { + // 如果指定了分组,检查用户是否有目标分组的 write 或 manage 权限 + let has_write = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "write", + ) + .await + .map_err(|e| e.to_string())?; + + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_write && !has_manage { + return Err("您没有在该分组中创建环境的权限".to_string()); + } + } else { + // 如果未指定分组,检查用户是否有团队级别的环境创建权限(Editor/Admin/Owner) + let can_create = matches!(team_member.role.as_str(), "owner" | "admin" | "editor"); + if !can_create { + return Err("您没有创建环境的权限,需要 Editor 及以上角色".to_string()); + } + } + + // 3. 检查工作空间配额是否充足 + let quota_available = models::check_quota(&svc_ctx.db, workspace_uuid, "environments") + .await + .map_err(|e| e.to_string())?; + if !quota_available { + return Err("工作空间环境配额不足,无法创建新环境".to_string()); + } + + // 提取系统信息 + let system_info = payload + .config + .window_info + .get("system") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let kernel_info = payload + .config + .window_info + .get("kernel") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // 创建环境 + let env_uuid = models::insert_environment( + &svc_ctx.db, + workspace_uuid, + user_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + payload.group_uuid, + payload.proxy_uuid, + system_info.as_deref(), + kernel_info.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + // 创建环境配置 + models::upsert_environment_config( + &svc_ctx.db, + env_uuid, + &payload.config.window_info, + &payload.config.basic_settings, + &payload.config.fingerprint_settings, + &payload.config.device_settings, + &payload.config.preference_settings, + &payload.config.project_metadata, + ) + .await + .map_err(|e| e.to_string())?; + + replace_environment_urls(svc_ctx, env_uuid, payload.urls.as_deref()).await?; + replace_environment_cookies(svc_ctx, env_uuid, payload.cookies.as_deref()).await?; + + // 分配标签 + if let Some(tag_uuids) = &payload.tag_uuids { + for tag_uuid in tag_uuids { + let _ = models::insert_environment_tag(&svc_ctx.db, env_uuid, *tag_uuid).await; + } + } + + // 关联账号 + if let Some(account_uuids) = &payload.account_uuids { + for (idx, account_uuid) in account_uuids.iter().enumerate() { + let _ = models::accounts::insert_environment_account( + &svc_ctx.db, + env_uuid, + *account_uuid, + idx as i32, + ) + .await; + } + } + + // 4. 更新工作空间配额(创建后增加使用数) + models::increment_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(env_uuid) +} + +#[derive(Debug, Clone)] +struct CookieSiteTarget { + site_input: String, + domain: String, + path: String, + secure: bool, +} + +fn normalize_cookie_path(path: &str) -> String { + if path.trim().is_empty() || path == "/" { + "/".to_string() + } else if path.starts_with('/') { + path.to_string() + } else { + format!("/{}", path) + } +} + +fn parse_cookie_site(site: &str) -> Result { + let site = site.trim(); + if site.is_empty() { + return Err("Cookie 目标网页/域名不能为空".to_string()); + } + + if let Ok(parsed) = Url::parse(site) { + let host = parsed + .host_str() + .ok_or_else(|| format!("无效的 Cookie 目标网页: {}", site))?; + + return Ok(CookieSiteTarget { + site_input: site.to_string(), + domain: host.to_string(), + path: normalize_cookie_path(parsed.path()), + secure: parsed.scheme().eq_ignore_ascii_case("https"), + }); + } + + if !site.contains("://") { + if site.starts_with('.') && !site.contains('/') && !site.chars().any(|ch| ch.is_whitespace()) { + return Ok(CookieSiteTarget { + site_input: site.to_string(), + domain: site.to_string(), + path: "/".to_string(), + secure: false, + }); + } + + let candidate = format!("https://{}", site); + if let Ok(parsed) = Url::parse(&candidate) { + if let Some(host) = parsed.host_str() { + return Ok(CookieSiteTarget { + site_input: site.to_string(), + domain: host.to_string(), + path: normalize_cookie_path(parsed.path()), + secure: false, + }); + } + } + } + + Err(format!("无效的 Cookie 目标网页/域名: {}", site)) +} + +fn is_cookie_attribute(part: &str) -> bool { + let lower = part.trim().to_lowercase(); + matches!( + lower.as_str(), + "secure" | "httponly" | "http-only" | "partitioned" + ) || lower.starts_with("domain=") + || lower.starts_with("path=") + || lower.starts_with("expires=") + || lower.starts_with("max-age=") + || lower.starts_with("samesite=") +} + +fn parse_cookie_name_value(part: &str) -> Result<(String, String), String> { + let Some(eq_pos) = part.find('=') else { + return Err(format!("无效的 Cookie 格式: {}", part)); + }; + + let name = part[..eq_pos].trim(); + let value = part[eq_pos + 1..].trim(); + if name.is_empty() { + return Err(format!("Cookie 名称不能为空: {}", part)); + } + + Ok((name.to_string(), value.to_string())) +} + +fn parse_cookie_line_with_attrs( + line: &str, + site_target: &CookieSiteTarget, +) -> Result { + let parts: Vec<&str> = line.split(';').map(|part| part.trim()).filter(|part| !part.is_empty()).collect(); + let (name, value) = parse_cookie_name_value( + parts + .first() + .copied() + .ok_or_else(|| "Cookie 内容不能为空".to_string())?, + )?; + + let mut domain = site_target.domain.clone(); + let mut path = site_target.path.clone(); + let mut http_only = false; + let mut secure = site_target.secure; + let mut same_site = Some("Lax".to_string()); + + for part in parts.iter().skip(1) { + let lower = part.to_lowercase(); + if lower.starts_with("domain=") { + domain = part[7..].trim().to_string(); + } else if lower.starts_with("path=") { + path = normalize_cookie_path(part[5..].trim()); + } else if lower == "secure" { + secure = true; + } else if lower == "httponly" || lower == "http-only" { + http_only = true; + } else if lower.starts_with("samesite=") { + same_site = Some(part[9..].trim().to_string()); + } + } + + Ok(CookieInput { + site_input: site_target.site_input.clone(), + domain, + name, + value, + path: Some(path), + http_only: Some(http_only), + secure: Some(secure), + same_site, + }) +} + +fn parse_cookie_group(group: &CookieGroupInput) -> Result, String> { + let site_target = parse_cookie_site(&group.site)?; + let cookie_text = group.cookie_text.trim(); + if cookie_text.is_empty() { + return Err("Cookie 内容不能为空".to_string()); + } + + let mut cookies = Vec::new(); + + for line in cookie_text + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + { + let parts: Vec<&str> = line + .split(';') + .map(|part| part.trim()) + .filter(|part| !part.is_empty()) + .collect(); + + if parts.is_empty() { + continue; + } + + let has_attr_style = parts.len() > 1 && parts.iter().skip(1).all(|part| is_cookie_attribute(part)); + if has_attr_style { + cookies.push(parse_cookie_line_with_attrs(line, &site_target)?); + continue; + } + + for part in parts { + let (name, value) = parse_cookie_name_value(part)?; + cookies.push(CookieInput { + site_input: site_target.site_input.clone(), + domain: site_target.domain.clone(), + name, + value, + path: Some(site_target.path.clone()), + http_only: Some(false), + secure: Some(site_target.secure), + same_site: Some("Lax".to_string()), + }); + } + } + + if cookies.is_empty() { + return Err("Cookie 内容不能为空".to_string()); + } + + Ok(cookies) +} + +fn format_cookie_row(cookie: &EnvironmentCookieDto, site_target: Option<&CookieSiteTarget>) -> String { + let mut parts = vec![format!("{}={}", cookie.name, cookie.value)]; + + let default_domain = site_target.map(|target| target.domain.as_str()).unwrap_or(""); + let default_path = site_target.map(|target| target.path.as_str()).unwrap_or("/"); + let default_secure = site_target.map(|target| target.secure).unwrap_or(false); + + if !cookie.domain.trim().is_empty() && cookie.domain != default_domain { + parts.push(format!("domain={}", cookie.domain)); + } + + let cookie_path = cookie.path.as_deref().unwrap_or("/"); + if cookie_path != default_path { + parts.push(format!("path={}", cookie_path)); + } + + if cookie.secure.unwrap_or(false) != default_secure && cookie.secure.unwrap_or(false) { + parts.push("secure".to_string()); + } + + if cookie.http_only.unwrap_or(false) { + parts.push("httpOnly".to_string()); + } + + if let Some(same_site) = cookie + .same_site + .as_deref() + .filter(|same_site| !same_site.trim().is_empty() && *same_site != "Lax") + { + parts.push(format!("sameSite={}", same_site)); + } + + parts.join("; ") +} + +fn group_cookie_rows(cookie_rows: Vec) -> Vec { + let mut grouped: HashMap> = HashMap::new(); + + for cookie in cookie_rows { + grouped + .entry(cookie.site_input.clone()) + .or_default() + .push(cookie); + } + + let mut items: Vec = grouped + .into_iter() + .map(|(site, cookies)| { + let site_target = parse_cookie_site(&site).ok(); + let simple_only = cookies.iter().all(|cookie| { + let default_domain = site_target + .as_ref() + .map(|target| target.domain.as_str()) + .unwrap_or(""); + let default_path = site_target + .as_ref() + .map(|target| target.path.as_str()) + .unwrap_or("/"); + let default_secure = site_target.as_ref().map(|target| target.secure).unwrap_or(false); + + cookie.domain == default_domain + && cookie.path.as_deref().unwrap_or("/") == default_path + && cookie.secure.unwrap_or(false) == default_secure + && !cookie.http_only.unwrap_or(false) + && cookie + .same_site + .as_deref() + .map(|same_site| same_site.eq_ignore_ascii_case("lax")) + .unwrap_or(true) + && cookie.expires_at.is_none() + }); + + let parts: Vec = cookies + .iter() + .map(|cookie| format_cookie_row(cookie, site_target.as_ref())) + .collect(); + + EnvironmentCookieGroupDto { + site, + cookie_text: if simple_only { + parts.join("; ") + } else { + parts.join("\n") + }, + } + }) + .collect(); + + items.sort_by(|left, right| left.site.cmp(&right.site)); + items +} + +async fn replace_environment_urls( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + urls: Option<&[UrlInput]>, +) -> Result<(), String> { + let Some(urls) = urls else { + return Ok(()); + }; + + models::clear_environment_urls(&svc_ctx.db, env_uuid) + .await + .map_err(|e| format!("清空 URLs 失败: {}", e))?; + + for (idx, item) in urls.iter().enumerate() { + let url = item.url.trim(); + if url.is_empty() { + continue; + } + + models::insert_environment_url( + &svc_ctx.db, + env_uuid, + url, + item.title.as_deref(), + item.sort_order.or(Some(idx as i32)), + ) + .await + .map_err(|e| format!("保存 URL 失败: {}", e))?; + } + + Ok(()) +} + +async fn replace_environment_cookies( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + cookie_groups: Option<&[CookieGroupInput]>, +) -> Result<(), String> { + let Some(cookie_groups) = cookie_groups else { + return Ok(()); + }; + + models::clear_environment_cookies(&svc_ctx.db, env_uuid) + .await + .map_err(|e| format!("清空 Cookies 失败: {}", e))?; + + let mut parsed_cookies = Vec::new(); + for group in cookie_groups { + let mut items = parse_cookie_group(group)?; + parsed_cookies.append(&mut items); + } + + if parsed_cookies.is_empty() { + return Ok(()); + } + + models::batch_insert_environment_cookies(&svc_ctx.db, env_uuid, &parsed_cookies) + .await + .map_err(|e| format!("保存 Cookies 失败: {}", e))?; + + Ok(()) +} + +/// 获取环境列表(包含完整关联数据) +pub async fn get_environments_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &ListEnvironmentsRequest, +) -> Result<(Vec, i64), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let group_uuid = payload.filters.as_ref().and_then(|f| f.group_uuid); + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let tag_uuids = payload.filters.as_ref().and_then(|f| f.tag_uuids.as_deref()); + + // 2. 查询环境总数(用于分页) + let total_count = models::fetch_environments_count( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + status, + keyword, + tag_uuids, + ) + .await + .map_err(|e| e.to_string())?; + + // 3. 查询环境基础列表 + let env_rows = models::fetch_environments_base( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + status, + keyword, + tag_uuids, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + // 2. 权限过滤:根据分组权限过滤环境 + let is_owner_or_admin = matches!(team_member.role.as_str(), "owner" | "admin"); + + // 收集所有需要检查权限的分组 UUID(去重) + let unique_group_uuids: Vec = env_rows + .iter() + .filter_map(|row| row.group_uuid) + .collect::>() + .into_iter() + .collect(); + + // 批量检查分组权限(如果不是 Owner/Admin) + let mut group_permissions_cache: std::collections::HashMap = + std::collections::HashMap::new(); + if !is_owner_or_admin && !unique_group_uuids.is_empty() { + for group_uuid in unique_group_uuids { + let has_permission = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "read", + ) + .await + .map_err(|e| e.to_string())?; + group_permissions_cache.insert(group_uuid, has_permission); + } + } + + // 8. 过滤无权限的环境 + let filtered_env_rows: Vec<_> = env_rows + .into_iter() + .filter(|row| { + if let Some(group_uuid) = row.group_uuid { + // 如果环境有分组,检查用户是否有分组的 read 权限 + if is_owner_or_admin { + true // Owner/Admin 自动拥有所有分组权限 + } else { + *group_permissions_cache.get(&group_uuid).unwrap_or(&false) + } + } else { + // 如果环境无分组,所有团队成员都可以查看(已在团队成员检查中验证) + true + } + }) + .collect(); + + // 9. 重新收集关联 UUID(基于过滤后的环境) + let env_uuids: Vec = filtered_env_rows.iter().map(|e| e.uuid).collect(); + let group_uuids: Vec = filtered_env_rows.iter().filter_map(|e| e.group_uuid).collect(); + let proxy_uuids: Vec = filtered_env_rows.iter().filter_map(|e| e.proxy_uuid).collect(); + + // 10. 重新查询关联数据(基于过滤后的环境) + // 批量查询环境配置 + let mut configs_map: HashMap = HashMap::new(); + if !env_uuids.is_empty() { + let config_rows = models::fetch_environment_configs_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + for config in config_rows { + configs_map.insert(config.environment_uuid, config); + } + } + + // 批量查询分组 + let group_rows = if !group_uuids.is_empty() { + models::fetch_groups_by_uuids(&svc_ctx.db, &group_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let groups_map: HashMap = group_rows.into_iter().map(|g| (g.uuid, g)).collect(); + + // 批量查询代理 + let proxy_rows = if !proxy_uuids.is_empty() { + models::fetch_proxies_by_uuids(&svc_ctx.db, &proxy_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let proxies_map: HashMap = proxy_rows.into_iter().map(|p| (p.uuid, p)).collect(); + + // 批量查询标签 + let tag_rows = if !env_uuids.is_empty() { + models::fetch_tags_for_environments(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + + // 按环境分组标签 + let mut tags_map: HashMap> = HashMap::new(); + for tag_row in tag_rows { + tags_map.entry(tag_row.environment_uuid).or_default().push(TagDto { + id: tag_row.tag_id, + uuid: tag_row.tag_uuid, + user_uuid: tag_row.tag_user_uuid, + team_uuid: tag_row.tag_team_uuid, + name: tag_row.tag_name, + color: tag_row.tag_color, + sort_order: tag_row.tag_sort_order, + environments_count: tag_row.tag_environments_count, + created_at: tag_row.tag_created_at, + updated_at: tag_row.tag_updated_at, + deleted_at: tag_row.tag_deleted_at, + }); + } + + let mut urls_map: HashMap> = HashMap::new(); + if !env_uuids.is_empty() { + let url_rows = models::fetch_environment_urls_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + for url in url_rows { + urls_map.entry(url.environment_uuid).or_default().push(url); + } + } + + let mut cookies_map: HashMap> = HashMap::new(); + if !env_uuids.is_empty() { + let cookie_rows = models::fetch_environment_cookies_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + let mut grouped_rows: HashMap> = HashMap::new(); + for cookie in cookie_rows { + grouped_rows + .entry(cookie.environment_uuid) + .or_default() + .push(cookie); + } + for (environment_uuid, rows) in grouped_rows { + cookies_map + .insert(environment_uuid, group_cookie_rows(rows)); + } + } + + // 批量查询账号 + let mut accounts_map: HashMap> = HashMap::new(); + for env_uuid in &env_uuids { + let accounts = accounts::get_environment_accounts_service(&svc_ctx, *env_uuid) + .await + .unwrap_or_default(); + accounts_map.insert(*env_uuid, accounts); + } + + // 批量查询扩展(为每个环境动态合并插件) + let mut extensions_map: HashMap> = + HashMap::new(); + for row in &filtered_env_rows { + let extensions = crate::services::extensions::get_environment_extensions_service( + &svc_ctx, + user_uuid, + team_uuid, + row.group_uuid, + ) + .await + .unwrap_or_default(); + extensions_map.insert(row.uuid, extensions); + } + + // 11. 组装完整数据(使用与环境详情一致的数据结构) + let environments: Vec = filtered_env_rows + .into_iter() + .map(|row| { + // 构建 EnvironmentDto + let environment = EnvironmentDto { + id: row.id, + uuid: row.uuid, + workspace_uuid: row.workspace_uuid, + user_uuid: row.user_uuid, + team_uuid: row.team_uuid, + name: row.name, + description: row.description, + status: row.status, + group_uuid: row.group_uuid, + proxy_uuid: row.proxy_uuid, + system_info: row.system_info, + kernel_info: row.kernel_info, + fingerprint_summary: row.fingerprint_summary, + last_opened_at: row.last_opened_at, + created_at: row.created_at, + updated_at: row.updated_at, + deleted_at: None, + }; + + // 分组详情 + let group = row.group_uuid.and_then(|uuid| { + groups_map.get(&uuid).map(|g| GroupSummaryDto { + id: g.id, + uuid: g.uuid, + name: g.name.clone(), + description: g.description.clone(), + sort_order: g.sort_order, + }) + }); + + // 代理详情 + let proxy = row.proxy_uuid.and_then(|uuid| { + proxies_map.get(&uuid).map(|p| ProxySummaryDto { + id: p.id, + uuid: p.uuid, + name: p.name.clone(), + host: p.host.clone(), + port: p.port, + proxy_type: p.proxy_type.clone(), + username: p.username.clone(), + password: p.password.clone(), + country: p.country.clone(), + city: p.city.clone(), + status: p.status.clone(), + latency: p.latency, + last_check_ip: p.last_check_ip.clone(), + }) + }); + + crate::entitys::EnvironmentDetailResponse { + environment, + config: configs_map.remove(&row.uuid), // 返回配置信息,用于传递给指纹浏览器内核 + cookies: cookies_map.remove(&row.uuid).unwrap_or_default(), + urls: urls_map.remove(&row.uuid).unwrap_or_default(), + tags: tags_map.remove(&row.uuid).unwrap_or_default(), + accounts: accounts_map.remove(&row.uuid).unwrap_or_default(), + group, + proxy, + extensions: extensions_map.remove(&row.uuid).unwrap_or_default(), + } + }) + .collect(); + + // 返回过滤后的环境列表和数据库总数(用于分页显示) + // 注意:total 是数据库中符合条件的总数,不考虑权限过滤 + // 权限过滤只影响当前页返回的数据,不影响总数统计 + Ok((environments, total_count)) +} + +/// 获取环境详情 +pub async fn get_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result { + // 1. 检查用户是否在当前工作空间的团队中 + let _team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 read/write/manage 权限 + // Owner/Admin 自动拥有所有分组权限(已在 check_group_permission 中处理) + let has_permission = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "read", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_permission { + return Err("您没有查看该环境的权限".to_string()); + } + } + // 如果环境无分组,所有团队成员都可以查看(已在团队成员检查中验证) + + Ok(environment) +} + +/// 获取环境配置 +pub async fn get_environment_config_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::fetch_environment_config(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境配置不存在".to_string()) +} + +/// 获取环境的标签 +pub async fn get_environment_tags_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_tags(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境详情(包含完整关联数据) +pub async fn get_environment_detail_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result { + let environment = + get_environment_service(svc_ctx, workspace_uuid, team_uuid, user_uuid, env_uuid).await?; + + let config = get_environment_config_service(svc_ctx, env_uuid).await.ok(); + let cookies = get_environment_cookies_service(svc_ctx, env_uuid) + .await + .unwrap_or_default(); + let urls = get_environment_urls_service(svc_ctx, env_uuid) + .await + .unwrap_or_default(); + + let tags = get_environment_tags_service(svc_ctx, env_uuid).await?; + + let accounts = accounts::get_environment_accounts_service(svc_ctx, env_uuid) + .await + .unwrap_or_default(); + + // 获取分组信息 + let group = if let Some(group_uuid) = environment.group_uuid { + get_group_summary_service(svc_ctx, group_uuid).await.ok() + } else { + None + }; + + // 获取代理信息 + let proxy = if let Some(proxy_uuid) = environment.proxy_uuid { + get_proxy_summary_service(svc_ctx, proxy_uuid).await.ok() + } else { + None + }; + + // 获取扩展列表 + let extensions = crate::services::extensions::get_environment_extensions_service( + svc_ctx, + user_uuid, + team_uuid, + environment.group_uuid, + ) + .await + .unwrap_or_default(); + + Ok(crate::entitys::EnvironmentDetailResponse { + environment, + config, + cookies, + urls, + tags, + accounts, + group, + proxy, + extensions, + }) +} + +/// 获取分组摘要信息 +pub async fn get_group_summary_service( + svc_ctx: &SvcCtx, + group_uuid: Uuid, +) -> Result { + let group = models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + Ok(crate::dto::GroupSummaryDto { + id: group.id, + uuid: group.uuid, + name: group.name, + description: group.description, + sort_order: group.sort_order, + }) +} + +/// 获取代理摘要信息 +pub async fn get_proxy_summary_service( + svc_ctx: &SvcCtx, + proxy_uuid: Uuid, +) -> Result { + let proxy = crate::models::proxies::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + Ok(crate::dto::ProxySummaryDto { + id: proxy.id, + uuid: proxy.uuid, + name: proxy.name, + host: proxy.host, + port: proxy.port, + proxy_type: proxy.proxy_type, + username: proxy.username, + password: proxy.password, + country: proxy.country, + city: proxy.city, + status: proxy.status, + latency: proxy.latency, + last_check_ip: proxy.last_check_ip, + }) +} + +/// 更新环境 +pub async fn update_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &UpdateEnvironmentRequest, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, payload.uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 write 或 manage 权限 + let has_write = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "write", + ) + .await + .map_err(|e| e.to_string())?; + + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_write && !has_manage { + return Err("您没有编辑该环境的权限".to_string()); + } + } else { + // 如果环境无分组,检查用户是否有团队级别的编辑权限(Editor/Admin/Owner) + let can_edit = matches!(team_member.role.as_str(), "owner" | "admin" | "editor"); + if !can_edit { + return Err("您没有编辑环境的权限,需要 Editor 及以上角色".to_string()); + } + } + + models::update_environment( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.group_uuid, + ) + .await + .map_err(|e| e.to_string())?; + + // 更新配置 + if let Some(config) = &payload.config { + models::upsert_environment_config( + &svc_ctx.db, + payload.uuid, + &config.window_info, + &config.basic_settings, + &config.fingerprint_settings, + &config.device_settings, + &config.preference_settings, + &config.project_metadata, + ) + .await + .map_err(|e| e.to_string())?; + } + + replace_environment_urls(svc_ctx, payload.uuid, payload.urls.as_deref()).await?; + replace_environment_cookies(svc_ctx, payload.uuid, payload.cookies.as_deref()).await?; + + Ok(()) +} + +/// 设置环境代理 +pub async fn set_environment_proxy_service( + svc_ctx: &SvcCtx, + payload: &SetEnvironmentProxyRequest, +) -> Result<(), String> { + models::update_environment_proxy(&svc_ctx.db, payload.uuid, payload.proxy_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 分配标签 +pub async fn assign_tags_service( + svc_ctx: &SvcCtx, + payload: &AssignTagsRequest, +) -> Result<(), String> { + for tag_uuid in &payload.tag_uuids { + models::insert_environment_tag(&svc_ctx.db, payload.uuid, *tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 移除标签 +pub async fn remove_tag_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + tag_uuid: Uuid, +) -> Result<(), String> { + models::remove_environment_tag(&svc_ctx.db, env_uuid, tag_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 移动到分组 +pub async fn move_to_group_service( + svc_ctx: &SvcCtx, + payload: &MoveToGroupRequest, +) -> Result<(), String> { + models::update_environment(&svc_ctx.db, payload.uuid, None, None, payload.group_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量移动到分组 +pub async fn batch_move_to_group_service( + svc_ctx: &SvcCtx, + payload: &BatchMoveToGroupRequest, +) -> Result<(), String> { + for env_uuid in &payload.env_uuids { + models::update_environment(&svc_ctx.db, *env_uuid, None, None, Some(payload.group_uuid)) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 批量分配标签 +pub async fn batch_assign_tags_service( + svc_ctx: &SvcCtx, + payload: &BatchAssignTagRequest, +) -> Result<(), String> { + for env_uuid in &payload.env_uuids { + models::insert_environment_tag(&svc_ctx.db, *env_uuid, payload.tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 批量移除标签 +pub async fn batch_remove_tags_service( + svc_ctx: &SvcCtx, + payload: &BatchRemoveTagsRequest, +) -> Result<(), String> { + if let Some(tag_uuid) = payload.tag_uuid { + // 移除指定的标签 + for env_uuid in &payload.env_uuids { + models::remove_environment_tag(&svc_ctx.db, *env_uuid, tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + } else { + // 移除所有标签 + for env_uuid in &payload.env_uuids { + models::clear_environment_tags(&svc_ctx.db, *env_uuid) + .await + .map_err(|e| e.to_string())?; + } + } + Ok(()) +} + +/// 删除环境 +pub async fn delete_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 manage 权限 + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_manage { + return Err("您没有删除该环境的权限".to_string()); + } + } else { + // 如果环境无分组,检查用户是否有团队级别的删除权限(Owner/Admin) + let can_delete = matches!(team_member.role.as_str(), "owner" | "admin"); + if !can_delete { + return Err("您没有删除环境的权限,需要 Owner 或 Admin 角色".to_string()); + } + } + + // 5. 删除环境 + models::delete_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + // 6. 更新工作空间配额(删除后减少使用数) + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 批量删除环境 +pub async fn batch_delete_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], +) -> Result { + models::batch_delete_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string()) +} + +// ============ Recycle Bin ============ + +/// 查询回收站环境列表 +pub async fn get_recycle_bin_environments_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &ListEnvironmentsRequest, +) -> Result<(Vec, i64), String> { + // 1. 提取过滤参数 + let group_uuid = payload.filters.as_ref().and_then(|f| f.group_uuid); + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + + // 2. 计算分页参数 + let page = payload.pagination.page; + let page_size = payload.pagination.page_size; + let offset = (page - 1) * page_size; + let limit = page_size; + + // 3. 查询回收站环境总数 + let total_count = models::fetch_deleted_environments_count( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + keyword, + ) + .await + .map_err(|e| e.to_string())?; + + // 4. 查询回收站环境基础列表 + let env_rows = models::fetch_deleted_environments_base( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + keyword, + offset, + limit, + ) + .await + .map_err(|e| e.to_string())?; + + if env_rows.is_empty() { + return Ok((vec![], total_count)); + } + + // 5. 获取所有环境的 UUID + let env_uuids: Vec = env_rows.iter().map(|e| e.uuid).collect(); + let group_uuids: Vec = env_rows.iter().filter_map(|e| e.group_uuid).collect(); + let proxy_uuids: Vec = env_rows.iter().filter_map(|e| e.proxy_uuid).collect(); + + // 6. 批量查询分组信息 + let group_rows = if !group_uuids.is_empty() { + models::fetch_groups_by_uuids(&svc_ctx.db, &group_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let group_map: HashMap = group_rows + .into_iter() + .map(|g| { + ( + g.uuid, + GroupSummaryDto { + id: g.id, + uuid: g.uuid, + name: g.name, + description: g.description, + sort_order: g.sort_order, + }, + ) + }) + .collect(); + + // 7. 批量查询代理信息 + let proxy_rows = if !proxy_uuids.is_empty() { + models::fetch_proxies_by_uuids(&svc_ctx.db, &proxy_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let proxy_map: HashMap = proxy_rows + .into_iter() + .map(|p| { + ( + p.uuid, + ProxySummaryDto { + id: p.id, + uuid: p.uuid, + name: p.name, + host: p.host, + port: p.port, + proxy_type: p.proxy_type, + username: p.username, + password: p.password, + country: p.country, + city: p.city, + status: p.status, + latency: p.latency, + last_check_ip: p.last_check_ip, + }, + ) + }) + .collect(); + + // 8. 批量查询标签信息 + let tag_rows = if !env_uuids.is_empty() { + models::fetch_tags_for_environments(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let mut env_tags_map: HashMap> = HashMap::new(); + for tag_row in tag_rows { + env_tags_map.entry(tag_row.environment_uuid).or_default().push(TagDto { + id: tag_row.tag_id, + uuid: tag_row.tag_uuid, + user_uuid: tag_row.tag_user_uuid, + team_uuid: tag_row.tag_team_uuid, + name: tag_row.tag_name, + color: tag_row.tag_color, + sort_order: tag_row.tag_sort_order, + environments_count: tag_row.tag_environments_count, + created_at: tag_row.tag_created_at, + updated_at: tag_row.tag_updated_at, + deleted_at: tag_row.tag_deleted_at, + }); + } + + let mut env_urls_map: HashMap> = HashMap::new(); + let url_rows = models::fetch_environment_urls_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + for url in url_rows { + env_urls_map + .entry(url.environment_uuid) + .or_default() + .push(url); + } + + let mut env_cookies_map: HashMap> = HashMap::new(); + let cookie_rows = models::fetch_environment_cookies_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + let mut grouped_cookie_rows: HashMap> = HashMap::new(); + for cookie in cookie_rows { + grouped_cookie_rows + .entry(cookie.environment_uuid) + .or_default() + .push(cookie); + } + for (environment_uuid, rows) in grouped_cookie_rows { + env_cookies_map + .insert(environment_uuid, group_cookie_rows(rows)); + } + + // 9. 批量查询账号信息 + let mut env_accounts_map: HashMap> = HashMap::new(); + for env_uuid in &env_uuids { + let accounts = accounts::get_environment_accounts_service(svc_ctx, *env_uuid) + .await + .unwrap_or_default(); + if !accounts.is_empty() { + env_accounts_map.insert(*env_uuid, accounts); + } + } + + // 10. 组装完整的环境信息 + let environments: Vec = env_rows + .into_iter() + .map(|row| { + let group = row.group_uuid.and_then(|gid| group_map.get(&gid).cloned()); + let proxy = row.proxy_uuid.and_then(|pid| proxy_map.get(&pid).cloned()); + let tags = env_tags_map.get(&row.uuid).cloned().unwrap_or_default(); + let accounts = env_accounts_map.get(&row.uuid).cloned().unwrap_or_default(); + + crate::entitys::EnvironmentDetailResponse { + environment: EnvironmentDto { + id: row.id, + uuid: row.uuid, + workspace_uuid: row.workspace_uuid, + user_uuid: row.user_uuid, + team_uuid: row.team_uuid, + name: row.name, + description: row.description, + status: row.status, + group_uuid: row.group_uuid, + proxy_uuid: row.proxy_uuid, + system_info: row.system_info, + kernel_info: row.kernel_info, + fingerprint_summary: row.fingerprint_summary, + last_opened_at: row.last_opened_at, + created_at: row.created_at, + updated_at: row.updated_at, + deleted_at: None, + }, + config: None, + cookies: env_cookies_map.remove(&row.uuid).unwrap_or_default(), + urls: env_urls_map.remove(&row.uuid).unwrap_or_default(), + tags, + accounts, + group, + proxy, + extensions: vec![], // 回收站不需要返回扩展数据 + } + }) + .collect(); + + Ok((environments, total_count)) +} + +/// 恢复环境 +pub async fn restore_environment_service(svc_ctx: &SvcCtx, env_uuid: Uuid) -> Result<(), String> { + models::restore_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量恢复环境 +pub async fn batch_restore_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], +) -> Result { + models::batch_restore_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// 永久删除环境 +pub async fn permanent_delete_environment_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result<(), String> { + // 永久删除环境 + models::permanent_delete_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新工作空间配额(永久删除后减少使用数) + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 批量永久删除环境 +pub async fn batch_permanent_delete_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], + workspace_uuid: Uuid, +) -> Result { + let count = models::batch_permanent_delete_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string())?; + + // 更新工作空间配额 + if count > 0 { + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, count as i32) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + } + + Ok(count) +} + +// ============ Environment URLs ============ + +/// 添加环境 URL +pub async fn add_environment_url_service( + svc_ctx: &SvcCtx, + payload: &AddEnvironmentUrlRequest, +) -> Result { + models::insert_environment_url( + &svc_ctx.db, + payload.environment_uuid, + &payload.url, + payload.title.as_deref(), + payload.sort_order, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境的所有 URL +pub async fn get_environment_urls_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_urls(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 删除环境 URL +pub async fn delete_environment_url_service(svc_ctx: &SvcCtx, url_id: i32) -> Result<(), String> { + models::delete_environment_url(&svc_ctx.db, url_id) + .await + .map_err(|e| e.to_string()) +} + +/// 清空环境的所有 URL +pub async fn clear_environment_urls_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::clear_environment_urls(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie +pub async fn add_environment_cookie_service( + svc_ctx: &SvcCtx, + payload: &AddEnvironmentCookieRequest, +) -> Result { + let cookies = parse_cookie_group(&CookieGroupInput { + site: payload.site.clone(), + cookie_text: payload.cookie_text.clone(), + })?; + + let mut last_id = 0; + for cookie in cookies { + last_id = models::insert_environment_cookie( + &svc_ctx.db, + payload.environment_uuid, + &cookie.site_input, + &cookie.domain, + &cookie.name, + &cookie.value, + cookie.path.as_deref(), + cookie.http_only, + cookie.secure, + cookie.same_site.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(last_id) +} + +/// 获取环境的所有 Cookies +pub async fn get_environment_cookies_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + let rows = models::fetch_environment_cookies(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok(group_cookie_rows(rows)) +} + +/// 删除环境 Cookie +pub async fn delete_environment_cookie_service( + svc_ctx: &SvcCtx, + cookie_id: i32, +) -> Result<(), String> { + models::delete_environment_cookie(&svc_ctx.db, cookie_id) + .await + .map_err(|e| e.to_string()) +} + +/// 清空环境的所有 Cookies +pub async fn clear_environment_cookies_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::clear_environment_cookies(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量创建环境 +pub async fn batch_create_environments_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &BatchCreateEnvironmentRequest, +) -> Result, String> { + let mut created_uuids = Vec::new(); + + for env_request in &payload.environments { + let env_uuid = + create_environment_service(svc_ctx, user_uuid, workspace_uuid, team_uuid, env_request) + .await + .map_err(|e| format!("创建环境 '{}' 失败: {}", env_request.name, e))?; + + created_uuids.push(env_uuid); + } + + Ok(created_uuids) +} diff --git a/server/src/services/extensions.rs b/server/src/services/extensions.rs new file mode 100644 index 00000000..9742b3aa --- /dev/null +++ b/server/src/services/extensions.rs @@ -0,0 +1,1030 @@ +use uuid::Uuid; + +use crate::dto::ExtensionDto; +use crate::entitys::{ + ExtensionGroup, InstallExtensionRequest, InstalledExtensionItem, ListExtensionsRequest, + UninstallExtensionRequest, +}; +use crate::models; +use crate::models::environments as env_models; +use crate::svc_ctx::SvcCtx; +use crate::utils::storage::get_objects; + +/// 获取扩展列表 +pub async fn get_extensions_service( + svc_ctx: &SvcCtx, + payload: &ListExtensionsRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let category = payload.filters.as_ref().and_then(|f| f.category.as_deref()); + let sort_by = payload.filters.as_ref().and_then(|f| f.sort_by.as_deref()); + let sort_order = payload.filters.as_ref().and_then(|f| f.sort_order.as_deref()); + + let mut extensions = models::extensions::fetch_extensions( + &svc_ctx.db, + keyword, + category, + sort_by, + sort_order, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + // 将 object path 转换为完整 URL + let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); + let extension_root = svc_ctx.config.storage.extension_root.as_str(); + ExtensionDto::transform_urls_batch(&mut extensions, public_base_url, extension_root); + + let total = models::extensions::fetch_extensions_count(&svc_ctx.db, keyword, category) + .await + .map_err(|e| e.to_string())?; + + Ok((extensions, total)) +} + +/// 获取扩展详情 +pub async fn get_extension_service( + svc_ctx: &SvcCtx, + extension_id: &str, +) -> Result { + let mut extension = models::extensions::fetch_extension_by_id(&svc_ctx.db, extension_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "扩展不存在".to_string())?; + + // 将 object path 转换为完整 URL + let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); + let extension_root = svc_ctx.config.storage.extension_root.as_str(); + extension.transform_urls(&public_base_url, &extension_root); + + Ok(extension) +} + +/// 获取扩展分类 +pub async fn get_extension_categories_service(svc_ctx: &SvcCtx) -> Result, String> { + models::extensions::fetch_extension_categories(&svc_ctx.db) + .await + .map_err(|e| e.to_string()) +} + +/// 获取用户已安装的扩展 +pub async fn get_user_installed_extensions_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, +) -> Result, String> { + let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); + let extension_root = svc_ctx.config.storage.extension_root.as_str(); + + // 查询用户直接安装的扩展 + let user_installed = models::extensions::fetch_user_extensions(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 查询用户相关的分组中安装的扩展 + let group_installed = + models::extensions::fetch_user_group_extensions(&svc_ctx.db, user_uuid, team_uuid) + .await + .map_err(|e| e.to_string())?; + + // 使用 HashMap 去重,以 extension_id 为 key + use std::collections::HashMap; + let mut extension_map: HashMap = HashMap::new(); + + // 处理用户直接安装的扩展 + for ue in user_installed { + if let Ok(Some(ext)) = + models::extensions::fetch_extension_by_id(&svc_ctx.db, &ue.extension_id).await + { + // 转换图标 URL + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = Some(get_objects::get_extension_icon_url( + &public_base_url, + &extension_root, + path, + )); + } + } + + // 查询关联的分组 + let group_uuids = models::extensions::fetch_group_uuids_by_extension_id( + &svc_ctx.db, + &ue.extension_id, + ) + .await + .unwrap_or_default(); + + let mut groups = Vec::new(); + for group_uuid in group_uuids { + if let Ok(Some(group)) = + env_models::fetch_group_by_uuid(&svc_ctx.db, group_uuid).await + { + groups.push(ExtensionGroup { + uuid: group.uuid, + name: group.name, + }); + } + } + + let has_update = ext.version != ue.installed_version; + extension_map.insert( + ue.extension_id.clone(), + InstalledExtensionItem { + extension_id: ue.extension_id, + name: ext.name, + version: ext.version, + installed_version: ue.installed_version, + has_update, + status: ue.status, + installed_at: ue.installed_at, + homepage: ext.homepage, + icon_url, + team_uuid: None, + scope: "user".to_string(), + description: ext.description, + category: Some(ext.category), + browser: Some(ext.browser), + developer: ext.developer, + downloads_count: ext.downloads_count, + rating: ext.rating, + permissions: ext.permissions, + file_size: ext.file_size, + updated_at: Some(ext.updated_at), + groups: if groups.is_empty() { + None + } else { + Some(groups) + }, + }, + ); + } + } + + // 处理分组安装的扩展 + for ge in group_installed { + // 如果已存在(用户直接安装),分组信息已经包含在内,直接跳过 + if extension_map.contains_key(&ge.extension_id) { + continue; + } + + // 只处理新扩展(仅安装在分组中,用户未直接安装) + if let Ok(Some(ext)) = + models::extensions::fetch_extension_by_id(&svc_ctx.db, &ge.extension_id).await + { + // 转换图标 URL + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = Some(get_objects::get_extension_icon_url( + &public_base_url, + &extension_root, + path, + )); + } + } + + // 查询关联的分组 + let group_uuids = models::extensions::fetch_group_uuids_by_extension_id( + &svc_ctx.db, + &ge.extension_id, + ) + .await + .unwrap_or_default(); + + let mut groups = Vec::new(); + let mut is_team_group = false; + for group_uuid in group_uuids { + if let Ok(Some(group)) = + env_models::fetch_group_by_uuid(&svc_ctx.db, group_uuid).await + { + if !group.team_uuid.is_nil() { + is_team_group = true; + } + groups.push(ExtensionGroup { + uuid: group.uuid, + name: group.name, + }); + } + } + + let has_update = ext.version != ge.installed_version; + let scope = if is_team_group { + "group-team".to_string() + } else { + "group-personal".to_string() + }; + extension_map.insert( + ge.extension_id.clone(), + InstalledExtensionItem { + extension_id: ge.extension_id, + name: ext.name, + version: ext.version, + installed_version: ge.installed_version, + has_update, + status: ge.status, + installed_at: ge.installed_at, + homepage: ext.homepage, + icon_url, + team_uuid: None, + scope, + description: ext.description, + category: Some(ext.category), + browser: Some(ext.browser), + developer: ext.developer, + downloads_count: ext.downloads_count, + rating: ext.rating, + permissions: ext.permissions, + file_size: ext.file_size, + updated_at: Some(ext.updated_at), + groups: if groups.is_empty() { + None + } else { + Some(groups) + }, + }, + ); + } + } + + Ok(extension_map.into_values().collect()) +} + +/// 获取团队已安装的扩展 +pub async fn get_team_installed_extensions_service( + svc_ctx: &SvcCtx, + team_uuid: Uuid, + user_uuid: Uuid, +) -> Result, String> { + let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); + let extension_root = svc_ctx.config.storage.extension_root.as_str(); + + // 查询团队直接安装的扩展 + let team_installed = models::extensions::fetch_team_extensions(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())?; + + // 查询团队相关的分组中安装的扩展 + let group_installed = models::extensions::fetch_team_group_extensions(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())?; + + // 查询用户禁用的团队插件列表 + let user_disabled_extensions = + models::extensions::fetch_user_disabled_team_extensions(&svc_ctx.db, user_uuid, team_uuid) + .await + .map_err(|e| e.to_string())?; + let user_disabled_set: std::collections::HashSet = + user_disabled_extensions.into_iter().collect(); + + // 使用 HashMap 去重,以 extension_id 为 key + use std::collections::HashMap; + let mut extension_map: HashMap = HashMap::new(); + + // 处理团队直接安装的扩展 + for te in team_installed { + if let Ok(Some(ext)) = + models::extensions::fetch_extension_by_id(&svc_ctx.db, &te.extension_id).await + { + // 转换图标 URL + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = Some(get_objects::get_extension_icon_url( + &public_base_url, + &extension_root, + path, + )); + } + } + + // 查询关联的分组(仅查询团队共享的分组) + let group_uuids = models::extensions::fetch_team_shared_group_uuids_by_extension_id( + &svc_ctx.db, + &te.extension_id, + ) + .await + .unwrap_or_default(); + + let mut groups = Vec::new(); + for group_uuid in group_uuids { + if let Ok(Some(group)) = + env_models::fetch_group_by_uuid(&svc_ctx.db, group_uuid).await + { + groups.push(ExtensionGroup { + uuid: group.uuid, + name: group.name, + }); + } + } + + let has_update = ext.version != te.installed_version; + + // 检查用户是否禁用了这个团队插件 + let status = if user_disabled_set.contains(&te.extension_id) { + "disabled".to_string() + } else { + te.status + }; + + extension_map.insert( + te.extension_id.clone(), + InstalledExtensionItem { + extension_id: te.extension_id, + name: ext.name, + version: ext.version, + installed_version: te.installed_version, + has_update, + status, + installed_at: te.installed_at, + homepage: ext.homepage, + icon_url, + team_uuid: Some(team_uuid), + scope: "team".to_string(), + description: ext.description, + category: Some(ext.category), + browser: Some(ext.browser), + developer: ext.developer, + downloads_count: ext.downloads_count, + rating: ext.rating, + permissions: ext.permissions, + file_size: ext.file_size, + updated_at: Some(ext.updated_at), + groups: if groups.is_empty() { + None + } else { + Some(groups) + }, + }, + ); + } + } + + // 处理分组安装的扩展 + for ge in group_installed { + // 如果已存在(团队直接安装),分组信息已经包含在内,直接跳过 + if extension_map.contains_key(&ge.extension_id) { + continue; + } + + // 只处理新扩展(仅安装在分组中,团队未直接安装) + if let Ok(Some(ext)) = + models::extensions::fetch_extension_by_id(&svc_ctx.db, &ge.extension_id).await + { + // 转换图标 URL + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = Some(get_objects::get_extension_icon_url( + &public_base_url, + &extension_root, + path, + )); + } + } + + // 查询关联的分组 + let group_uuids = models::extensions::fetch_group_uuids_by_extension_id( + &svc_ctx.db, + &ge.extension_id, + ) + .await + .unwrap_or_default(); + + let mut groups = Vec::new(); + for group_uuid in group_uuids { + if let Ok(Some(group)) = + env_models::fetch_group_by_uuid(&svc_ctx.db, group_uuid).await + { + groups.push(ExtensionGroup { + uuid: group.uuid, + name: group.name, + }); + } + } + + let has_update = ext.version != ge.installed_version; + let scope = if ge.is_team_shared { + "group-team".to_string() + } else { + "group-personal".to_string() + }; + + // 检查用户是否禁用了这个团队插件 + let status = if user_disabled_set.contains(&ge.extension_id) { + "disabled".to_string() + } else { + ge.status + }; + + extension_map.insert( + ge.extension_id.clone(), + InstalledExtensionItem { + extension_id: ge.extension_id, + name: ext.name, + version: ext.version, + installed_version: ge.installed_version, + has_update, + status, + installed_at: ge.installed_at, + homepage: ext.homepage, + icon_url, + team_uuid: Some(team_uuid), + scope, + description: ext.description, + category: Some(ext.category), + browser: Some(ext.browser), + developer: ext.developer, + downloads_count: ext.downloads_count, + rating: ext.rating, + permissions: ext.permissions, + file_size: ext.file_size, + updated_at: Some(ext.updated_at), + groups: if groups.is_empty() { + None + } else { + Some(groups) + }, + }, + ); + } + } + + Ok(extension_map.into_values().collect()) +} + +/// 安装扩展 +pub async fn install_extension_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &InstallExtensionRequest, +) -> Result<(), String> { + // 获取扩展信息 + let extension = models::extensions::fetch_extension_by_id(&svc_ctx.db, &payload.extension_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "扩展不存在".to_string())?; + + let target_type = payload.target_type.as_deref().unwrap_or("user"); + + match target_type { + "user" => { + models::extensions::insert_user_extension( + &svc_ctx.db, + user_uuid, + &payload.extension_id, + &extension.version, + ) + .await + .map_err(|e| e.to_string())?; + } + "team" => { + // 权限检查:检查用户是否为 owner/admin + let team_uuid = team_uuid.ok_or_else(|| "未指定团队".to_string())?; + + // 获取工作空间 UUID + let team = crate::models::teams::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + let workspace_uuid = team.workspace_uuid; + + // 查询用户在团队中的角色 + let member = crate::models::teams::fetch_team_member( + &svc_ctx.db, + workspace_uuid, + team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队成员".to_string())?; + + // 检查角色权限 + if member.role != "owner" && member.role != "admin" { + return Err("权限不足:只有团队所有者或管理员可以安装团队插件".to_string()); + } + + models::extensions::insert_team_extension( + &svc_ctx.db, + team_uuid, + &payload.extension_id, + &extension.version, + user_uuid, + ) + .await + .map_err(|e| e.to_string())?; + } + "group" => { + // 权限检查 + let is_team_shared = payload.is_team_shared.unwrap_or(false); + + // 必须提供分组数组,即使只有一个分组也需要传入数组 + let group_ids = payload.group_ids.as_ref().ok_or_else(|| "未指定分组".to_string())?; + if group_ids.is_empty() { + return Err("分组列表不能为空".to_string()); + } + + for group_uuid in group_ids { + // 检查权限 + // - is_team_shared=true: 需要"管理"权限 + // - is_team_shared=false: 需要"编辑"权限 + let required_permission = if is_team_shared { "manage" } else { "write" }; + + // 获取工作空间 UUID(从团队获取) + let workspace_uuid = if let Some(team_uuid) = team_uuid { + let team = crate::models::teams::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + team.workspace_uuid + } else { + return Err("未指定团队".to_string()); + }; + + let has_permission = crate::models::group_member_permissions::check_group_permission( + &svc_ctx.db, + workspace_uuid, + *group_uuid, + user_uuid, + required_permission, + ) + .await + .map_err(|e| e.to_string())?; + + if !has_permission { + let msg = if is_team_shared { + "权限不足:安装团队共享插件需要分组的管理权限" + } else { + "权限不足:安装个人插件需要分组的编辑权限" + }; + return Err(msg.to_string()); + } + + models::extensions::insert_group_extension( + &svc_ctx.db, + *group_uuid, + &payload.extension_id, + &extension.version, + user_uuid, + is_team_shared, + ) + .await + .map_err(|e| e.to_string())?; + } + } + _ => return Err("无效的安装目标类型".to_string()), + } + + // 添加审计日志 + let details = format!("安装扩展: {}", payload.extension_id); + let changes = serde_json::json!({ + "extension_id": payload.extension_id, + "version": extension.version, + "target_type": target_type, + "is_team_shared": payload.is_team_shared, + }); + + let _ = crate::models::audit::insert_audit_log( + &svc_ctx.db, + user_uuid, + team_uuid, + "install_extension", + target_type, + None, + Some(&payload.extension_id), + Some(&details), + Some(&changes), + None, + None, + None, + ) + .await; + + Ok(()) +} + +/// 卸载扩展 +pub async fn uninstall_extension_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &UninstallExtensionRequest, +) -> Result<(), String> { + let target_type = payload.target_type.as_deref().unwrap_or("user"); + + match target_type { + "user" => { + models::extensions::delete_user_extension( + &svc_ctx.db, + user_uuid, + &payload.extension_id, + ) + .await + .map_err(|e| e.to_string())?; + } + "team" => { + // 权限检查:检查用户是否为 owner/admin + let team_uuid = team_uuid.ok_or_else(|| "未指定团队".to_string())?; + + // 获取工作空间 UUID + let team = crate::models::teams::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + let workspace_uuid = team.workspace_uuid; + + // 查询用户在团队中的角色 + let member = crate::models::teams::fetch_team_member( + &svc_ctx.db, + workspace_uuid, + team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队成员".to_string())?; + + // 检查角色权限 + if member.role != "owner" && member.role != "admin" { + return Err("权限不足:只有团队所有者或管理员可以卸载团队插件".to_string()); + } + + models::extensions::delete_team_extension( + &svc_ctx.db, + team_uuid, + &payload.extension_id, + ) + .await + .map_err(|e| e.to_string())?; + } + "group" => { + // 权限检查 + let group_uuid = payload.target_uuid.ok_or_else(|| "未指定分组".to_string())?; + + // 查询该分组扩展的 is_team_shared 状态 + let group_extensions = models::extensions::fetch_group_extensions(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())?; + + let group_ext = group_extensions + .iter() + .find(|ge| ge.extension_id == payload.extension_id) + .ok_or_else(|| "该分组未安装此扩展".to_string())?; + + // 根据 is_team_shared 检查权限 + let required_permission = if group_ext.is_team_shared { "manage" } else { "write" }; + + // 获取工作空间 UUID + let workspace_uuid = if let Some(team_uuid) = team_uuid { + let team = crate::models::teams::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + team.workspace_uuid + } else { + return Err("未指定团队".to_string()); + }; + + let has_permission = crate::models::group_member_permissions::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + required_permission, + ) + .await + .map_err(|e| e.to_string())?; + + if !has_permission { + let msg = if group_ext.is_team_shared { + "权限不足:卸载团队共享插件需要分组的管理权限" + } else { + "权限不足:卸载个人插件需要分组的编辑权限" + }; + return Err(msg.to_string()); + } + + models::extensions::delete_group_extension( + &svc_ctx.db, + group_uuid, + &payload.extension_id, + ) + .await + .map_err(|e| e.to_string())?; + } + _ => return Err("无效的卸载目标类型".to_string()), + } + + // 添加审计日志 + let details = format!("卸载扩展: {}", payload.extension_id); + let changes = serde_json::json!({ + "extension_id": payload.extension_id, + "target_type": target_type, + }); + + let _ = crate::models::audit::insert_audit_log( + &svc_ctx.db, + user_uuid, + team_uuid, + "uninstall_extension", + target_type, + payload.target_uuid, + Some(&payload.extension_id), + Some(&details), + Some(&changes), + None, + None, + None, + ) + .await; + + Ok(()) +} + +/// 更新扩展 +pub async fn update_extension_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + extension_id: &str, +) -> Result<(), String> { + // 获取最新扩展信息 + let extension = models::extensions::fetch_extension_by_id(&svc_ctx.db, extension_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "扩展不存在".to_string())?; + + // 更新用户扩展版本 + models::extensions::insert_user_extension( + &svc_ctx.db, + user_uuid, + extension_id, + &extension.version, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} + +/// 批量更新扩展 +pub async fn batch_update_extensions_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + extension_ids: &[String], +) -> Result { + let mut updated = 0u64; + + for extension_id in extension_ids { + if update_extension_service(svc_ctx, user_uuid, extension_id).await.is_ok() { + updated += 1; + } + } + + Ok(updated) +} + +/// 获取环境的扩展列表(动态合并 4 个层级) +/// +/// 合并逻辑: +/// 1. 用户个人全局插件 +/// 2. 团队全局插件 +/// 3. 该环境所属分组的个人插件 +/// 4. 该环境所属分组的团队插件 +pub async fn get_environment_extensions_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, +) -> Result, String> { + use std::collections::HashMap; + + let public_base_url = svc_ctx.config.storage.public_base_url.as_str(); + let extension_root = svc_ctx.config.storage.extension_root.as_str(); + + // 使用 HashMap 去重,key 为 extension_id + let mut extension_map: HashMap = HashMap::new(); + + // 0. 查询用户禁用的插件列表 + let disabled_extensions: std::collections::HashSet = sqlx::query_scalar::<_, String>( + r#" + SELECT extension_id FROM user_extensions + WHERE user_uuid = $1 AND status = 'disabled' + "#, + ) + .bind(user_uuid) + .fetch_all(&svc_ctx.db) + .await + .map_err(|e| e.to_string())? + .into_iter() + .collect(); + + // 1. 查询用户全局插件(排除 disabled 状态) + let user_extensions = models::extensions::fetch_user_extensions(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + for ue in user_extensions { + if let Ok(Some(ext)) = models::extensions::fetch_extension_by_id(&svc_ctx.db, &ue.extension_id).await { + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = Some(crate::utils::storage::get_objects::get_extension_icon_url( + public_base_url, + extension_root, + path, + )); + } + } + + let mut download_url = ext.download_url.clone(); + if let Some(path) = &download_url { + if !path.is_empty() && !path.starts_with("http") { + download_url = Some(crate::utils::storage::get_objects::get_extension_crx_url( + public_base_url, + extension_root, + path, + )); + } + } + + extension_map.insert( + ue.extension_id.clone(), + crate::dto::environments::ExtensionSummaryDto { + extension_id: ue.extension_id, + name: ext.name, + version: ext.version, + icon_url, + download_url, + hash: ext.hash, + scope: "user".to_string(), + }, + ); + } + } + + // 2. 查询团队全局插件 + let team_extensions = models::extensions::fetch_team_extensions(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())?; + + for te in team_extensions { + // 跳过用户禁用的插件 + if disabled_extensions.contains(&te.extension_id) { + continue; + } + + if let Ok(Some(ext)) = models::extensions::fetch_extension_by_id(&svc_ctx.db, &te.extension_id).await { + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = Some(crate::utils::storage::get_objects::get_extension_icon_url( + public_base_url, + extension_root, + path, + )); + } + } + + let mut download_url = ext.download_url.clone(); + if let Some(path) = &download_url { + if !path.is_empty() && !path.starts_with("http") { + download_url = Some(crate::utils::storage::get_objects::get_extension_crx_url( + public_base_url, + extension_root, + path, + )); + } + } + + extension_map.insert( + te.extension_id.clone(), + crate::dto::environments::ExtensionSummaryDto { + extension_id: te.extension_id, + name: ext.name, + version: ext.version, + icon_url, + download_url, + hash: ext.hash, + scope: "team".to_string(), + }, + ); + } + } + + // 3. 如果环境有分组,查询分组插件 + if let Some(group_uuid) = group_uuid { + let group_extensions = models::extensions::fetch_group_extensions(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())?; + + for ge in group_extensions { + // 跳过用户禁用的插件 + if disabled_extensions.contains(&ge.extension_id) { + continue; + } + + if let Ok(Some(ext)) = models::extensions::fetch_extension_by_id(&svc_ctx.db, &ge.extension_id).await { + let mut icon_url = ext.icon_url.clone(); + if let Some(path) = &icon_url { + if !path.is_empty() && !path.starts_with("http") { + icon_url = Some(crate::utils::storage::get_objects::get_extension_icon_url( + public_base_url, + extension_root, + path, + )); + } + } + + let mut download_url = ext.download_url.clone(); + if let Some(path) = &download_url { + if !path.is_empty() && !path.starts_with("http") { + download_url = Some(crate::utils::storage::get_objects::get_extension_crx_url( + public_base_url, + extension_root, + path, + )); + } + } + + let scope = if ge.is_team_shared { + "group-team".to_string() + } else { + "group-personal".to_string() + }; + + extension_map.insert( + ge.extension_id.clone(), + crate::dto::environments::ExtensionSummaryDto { + extension_id: ge.extension_id, + name: ext.name, + version: ext.version, + icon_url, + download_url, + hash: ext.hash, + scope, + }, + ); + } + } + } + + Ok(extension_map.into_values().collect()) +} + +/// 禁用扩展(用户级别) +/// +/// 用户可以禁用团队插件,通过在 user_team_extension_preferences 中设置 is_disabled = true +pub async fn disable_extension_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Uuid, + extension_id: &str, +) -> Result<(), String> { + // 检查扩展是否存在 + let _extension = models::extensions::fetch_extension_by_id(&svc_ctx.db, extension_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "扩展不存在".to_string())?; + + // 设置用户对团队插件的禁用状态 + models::extensions::set_user_team_extension_preference( + &svc_ctx.db, + user_uuid, + team_uuid, + extension_id, + true, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} + +/// 启用扩展(用户级别) +/// +/// 删除用户对团队插件的禁用设置 +pub async fn enable_extension_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Uuid, + extension_id: &str, +) -> Result<(), String> { + // 删除用户对团队插件的偏好设置(或设置为 false) + models::extensions::delete_user_team_extension_preference( + &svc_ctx.db, + user_uuid, + team_uuid, + extension_id, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} diff --git a/server/src/services/group_permissions.rs b/server/src/services/group_permissions.rs new file mode 100644 index 00000000..697af9a6 --- /dev/null +++ b/server/src/services/group_permissions.rs @@ -0,0 +1,139 @@ +use uuid::Uuid; + +use crate::dto::GroupMemberPermissionDto; +use crate::entitys::{ + CheckGroupPermissionRequest, GrantGroupPermissionRequest, ListUserGroupPermissionsRequest, + RevokeGroupPermissionRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 授予分组权限 +pub async fn grant_group_permission_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &GrantGroupPermissionRequest, +) -> Result<(), String> { + // 检查权限:只有团队 Owner/Admin 或拥有分组 manage 权限的用户可以授权 + let group = models::fetch_group_by_uuid(&svc_ctx.db, payload.group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 检查用户是否是团队成员(工作空间级别) + let team_member = models::fetch_team_member( + &svc_ctx.db, + group.workspace_uuid, + group.team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // Owner/Admin 自动拥有所有权限 + let can_grant = team_member.role == "owner" + || team_member.role == "admin" + || models::check_group_permission( + &svc_ctx.db, + group.workspace_uuid, + payload.group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !can_grant { + return Err("您没有权限授予分组权限".to_string()); + } + + models::grant_group_permission( + &svc_ctx.db, + payload.group_uuid, + group.workspace_uuid, + group.team_uuid, + payload.user_uuid, + &payload.permission_type, + user_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 撤销分组权限 +pub async fn revoke_group_permission_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &RevokeGroupPermissionRequest, +) -> Result<(), String> { + // 检查权限:只有团队 Owner/Admin 或拥有分组 manage 权限的用户可以撤销权限 + let group = models::fetch_group_by_uuid(&svc_ctx.db, payload.group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 检查用户是否是团队成员(工作空间级别) + let team_member = models::fetch_team_member( + &svc_ctx.db, + group.workspace_uuid, + group.team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // Owner/Admin 自动拥有所有权限 + let can_revoke = team_member.role == "owner" + || team_member.role == "admin" + || models::check_group_permission( + &svc_ctx.db, + group.workspace_uuid, + payload.group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !can_revoke { + return Err("您没有权限撤销分组权限".to_string()); + } + + models::revoke_group_permission(&svc_ctx.db, payload.group_uuid, payload.user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 检查分组权限 +pub async fn check_group_permission_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + payload: &CheckGroupPermissionRequest, +) -> Result { + models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + payload.group_uuid, + payload.user_uuid, + &payload.permission_type, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 查询用户的分组权限列表 +pub async fn list_user_group_permissions_service( + svc_ctx: &SvcCtx, + payload: &ListUserGroupPermissionsRequest, +) -> Result, String> { + models::fetch_user_group_permissions( + &svc_ctx.db, + payload.user_uuid, + None, // workspace_uuid 可以从 payload 中获取,如果需要的话 + payload.group_uuid, + ) + .await + .map_err(|e| e.to_string()) +} diff --git a/server/src/services/groups.rs b/server/src/services/groups.rs new file mode 100644 index 00000000..fefbf3da --- /dev/null +++ b/server/src/services/groups.rs @@ -0,0 +1,158 @@ +use uuid::Uuid; + +use crate::dto::GroupDto; +use crate::entitys::{CreateGroupRequest, UpdateGroupRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建分组 +pub async fn create_group_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &CreateGroupRequest, +) -> Result { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 检查用户是否是团队 Owner/Admin(只有 Owner/Admin 可以创建分组) + let can_create = matches!(team_member.role.as_str(), "owner" | "admin"); + if !can_create { + return Err("您没有创建分组的权限,需要 Owner 或 Admin 角色".to_string()); + } + + models::insert_group( + &svc_ctx.db, + workspace_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + user_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取分组列表 +pub async fn get_groups_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + page: i64, + page_size: i64, +) -> Result, String> { + let offset = (page - 1) * page_size; + models::fetch_groups(&svc_ctx.db, workspace_uuid, team_uuid, offset, page_size) + .await + .map_err(|e| e.to_string()) +} + +/// 获取分组详情 +pub async fn get_group_service(svc_ctx: &SvcCtx, group_uuid: Uuid) -> Result { + models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string()) +} + +/// 更新分组 +pub async fn update_group_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &UpdateGroupRequest, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询分组 + let group = models::fetch_group_by_uuid(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 3. 验证分组属于指定工作空间和团队 + if group.workspace_uuid != workspace_uuid || group.team_uuid != team_uuid { + return Err("分组不属于指定工作空间或团队".to_string()); + } + + // 4. 检查用户是否是团队 Owner/Admin 或拥有分组的 manage 权限 + let is_owner_or_admin = matches!(team_member.role.as_str(), "owner" | "admin"); + let has_manage = if !is_owner_or_admin { + models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + payload.uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())? + } else { + true + }; + + if !has_manage { + return Err("您没有管理该分组的权限".to_string()); + } + + models::update_group( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.sort_order, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除分组 +pub async fn delete_group_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + group_uuid: Uuid, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询分组 + let group = models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 3. 验证分组属于指定工作空间和团队 + if group.workspace_uuid != workspace_uuid || group.team_uuid != team_uuid { + return Err("分组不属于指定工作空间或团队".to_string()); + } + + // 4. 检查用户是否是团队 Owner/Admin 或拥有分组的 manage 权限 + let is_owner_or_admin = matches!(team_member.role.as_str(), "owner" | "admin"); + let has_manage = if !is_owner_or_admin { + models::check_group_permission(&svc_ctx.db, workspace_uuid, group_uuid, user_uuid, "manage") + .await + .map_err(|e| e.to_string())? + } else { + true + }; + + if !has_manage { + return Err("您没有删除该分组的权限".to_string()); + } + + models::delete_group(&svc_ctx.db, group_uuid).await.map_err(|e| e.to_string()) +} diff --git a/server/src/services/local_api.rs b/server/src/services/local_api.rs new file mode 100644 index 00000000..3f66fa1d --- /dev/null +++ b/server/src/services/local_api.rs @@ -0,0 +1,459 @@ +use chrono::Utc; +use uuid::Uuid; + +use crate::caches::{ + LocalApiKeyCache, LocalApiPermissionCache, delete_local_api_key_cache, + delete_local_api_permission_caches_for_key, + get_local_api_key_cache, get_local_api_permission_cache, get_local_api_permission_definition_cache, + get_local_api_rate_count, increment_local_api_rate_count, set_local_api_key_cache, + set_local_api_permission_cache, set_local_api_permission_definition_cache, +}; +use crate::dto::{LocalApiConfigDto, ResetLocalApiKeyDto, ValidateLocalApiKeyDto}; +use crate::entitys::{UpdateLocalApiConfigRequest, ValidateLocalApiKeyRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +const DEFAULT_DAILY_LIMIT: i32 = 1000; + +pub async fn get_local_api_config_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + init_local_api_for_user_service(svc_ctx, user_uuid).await?; + + let settings = models::local_api::fetch_local_api_settings(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "API 服务配置不存在".to_string())?; + + let mut api_key = models::local_api::fetch_active_api_key(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "API 密钥不存在".to_string())?; + + if api_key.api_key.is_none() { + rotate_local_api_key_for_user(svc_ctx, user_uuid).await?; + api_key = models::local_api::fetch_active_api_key(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "API 密钥不存在".to_string())?; + } + + Ok(LocalApiConfigDto { + enabled: settings.enabled, + api_key: api_key + .api_key + .clone() + .ok_or_else(|| "API 密钥不存在".to_string())?, + port: settings.port, + remote_access: settings.remote_access, + cors_origins: models::local_api::parse_cors_origins(&settings.cors_origins), + requests_today: get_local_api_rate_count( + svc_ctx, + api_key.id, + None, + "day", + &Utc::now().format("%Y%m%d").to_string(), + ) + .await + .ok() + .map(|count| count as i32) + .unwrap_or(api_key.requests_today), + daily_limit: api_key.daily_limit, + }) +} + +pub async fn update_local_api_config_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdateLocalApiConfigRequest, +) -> Result { + if let Some(port) = payload.port { + if !(1..=65535).contains(&port) { + return Err("端口范围无效".to_string()); + } + } + + init_local_api_for_user_service(svc_ctx, user_uuid).await?; + + let cors_origins = payload + .cors_origins + .as_ref() + .map(|origins| models::local_api::build_cors_origins_value(origins)); + + models::local_api::upsert_local_api_settings( + &svc_ctx.db, + user_uuid, + payload.enabled, + payload.port, + payload.remote_access, + cors_origins.as_ref(), + ) + .await + .map_err(|e| e.to_string())?; + + get_local_api_config_service(svc_ctx, user_uuid).await +} + +pub async fn reset_local_api_key_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + init_local_api_for_user_service(svc_ctx, user_uuid).await?; + + if let Some(current_key) = models::local_api::fetch_active_api_key(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + { + delete_local_api_key_cache(svc_ctx, ¤t_key.key_hash) + .await + .map_err(|e| e.to_string())?; + delete_local_api_permission_caches_for_key(svc_ctx, current_key.id) + .await + .map_err(|e| e.to_string())?; + } + + models::local_api::deactivate_api_keys_for_user(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let api_key = generate_api_key(); + let key_hash = models::local_api::hash_api_key(&api_key); + let key_prefix = api_key.chars().take(16).collect::(); + let created_key = models::local_api::insert_api_key( + &svc_ctx.db, + user_uuid, + &key_prefix, + &key_hash, + &api_key, + DEFAULT_DAILY_LIMIT, + ) + .await + .map_err(|e| e.to_string())?; + set_local_api_key_cache( + svc_ctx, + &key_hash, + &LocalApiKeyCache { + id: created_key.id, + user_uuid: created_key.user_uuid, + is_active: created_key.is_active, + expires_at: created_key.expires_at, + daily_limit: created_key.daily_limit, + }, + ) + .await + .map_err(|e| e.to_string())?; + + ensure_default_permissions(svc_ctx, created_key.id).await?; + + Ok(ResetLocalApiKeyDto { + api_key, + }) +} + +pub async fn validate_local_api_key_service( + svc_ctx: &SvcCtx, + payload: &ValidateLocalApiKeyRequest, +) -> Result { + let key_hash = models::local_api::hash_api_key(payload.api_key.as_str()); + let api_key = if let Some(cache) = get_local_api_key_cache(svc_ctx, &key_hash) + .await + .map_err(|e| e.to_string())? + { + cache + } else { + let db_api_key = models::local_api::fetch_api_key_by_hash(&svc_ctx.db, &key_hash) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "API 密钥无效".to_string())?; + let cache = LocalApiKeyCache { + id: db_api_key.id, + user_uuid: db_api_key.user_uuid, + is_active: db_api_key.is_active, + expires_at: db_api_key.expires_at, + daily_limit: db_api_key.daily_limit, + }; + set_local_api_key_cache(svc_ctx, &key_hash, &cache) + .await + .map_err(|e| e.to_string())?; + cache + }; + + if !api_key.is_active { + return Err("API 密钥已停用".to_string()); + } + + if let Some(expires_at) = api_key.expires_at { + if expires_at < Utc::now() { + return Err("API 密钥已过期".to_string()); + } + } + + let day_key = Utc::now().format("%Y%m%d").to_string(); + let day_count = get_local_api_rate_count(svc_ctx, api_key.id, None, "day", &day_key) + .await + .map_err(|e| e.to_string())?; + if day_count >= i64::from(api_key.daily_limit) { + return Err("API 密钥已达到今日调用上限".to_string()); + } + + let definition = if let Some(cache) = + get_local_api_permission_definition_cache(svc_ctx, &payload.permission_code) + .await + .map_err(|e| e.to_string())? + { + cache + } else { + let definition = models::local_api::fetch_permission_definition( + &svc_ctx.db, + &payload.permission_code, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "未知的 permissionCode".to_string())?; + set_local_api_permission_definition_cache(svc_ctx, &payload.permission_code, &definition) + .await + .map_err(|e| e.to_string())?; + definition + }; + + let permission = if let Some(cache) = + get_local_api_permission_cache(svc_ctx, api_key.id, &payload.permission_code) + .await + .map_err(|e| e.to_string())? + { + cache + } else { + let db_permission = fetch_effective_permission(svc_ctx, api_key.id, &payload.permission_code) + .await? + .ok_or_else(|| "当前密钥无权访问该接口".to_string())?; + let cache = LocalApiPermissionCache { + is_enabled: db_permission.is_enabled, + rate_limit_per_minute: db_permission.rate_limit_per_minute, + rate_limit_per_hour: db_permission.rate_limit_per_hour, + }; + set_local_api_permission_cache(svc_ctx, api_key.id, &payload.permission_code, &cache) + .await + .map_err(|e| e.to_string())?; + cache + }; + + if !permission.is_enabled { + return Err("当前密钥已被禁止访问该接口".to_string()); + } + + let now = Utc::now(); + let minute_key = now.format("%Y%m%d%H%M").to_string(); + let hour_key = now.format("%Y%m%d%H").to_string(); + + let minute_count = get_local_api_rate_count( + svc_ctx, + api_key.id, + Some(payload.permission_code.as_str()), + "minute", + &minute_key, + ) + .await + .map_err(|e| e.to_string())?; + if minute_count >= i64::from(permission.rate_limit_per_minute) { + return Err("接口每分钟调用次数已达上限".to_string()); + } + + let hour_count = get_local_api_rate_count( + svc_ctx, + api_key.id, + Some(payload.permission_code.as_str()), + "hour", + &hour_key, + ) + .await + .map_err(|e| e.to_string())?; + if hour_count >= i64::from(permission.rate_limit_per_hour) { + return Err("接口每小时调用次数已达上限".to_string()); + } + + increment_local_api_rate_count( + svc_ctx, + api_key.id, + Some(payload.permission_code.as_str()), + "minute", + &minute_key, + ) + .await + .map_err(|e| e.to_string())?; + increment_local_api_rate_count( + svc_ctx, + api_key.id, + Some(payload.permission_code.as_str()), + "hour", + &hour_key, + ) + .await + .map_err(|e| e.to_string())?; + increment_local_api_rate_count(svc_ctx, api_key.id, None, "day", &day_key) + .await + .map_err(|e| e.to_string())?; + + Ok(ValidateLocalApiKeyDto { + valid: true, + permission_code: definition.permission_code, + requests_today: (day_count + 1) as i32, + daily_limit: api_key.daily_limit, + rate_limit_per_minute: permission.rate_limit_per_minute, + rate_limit_per_hour: permission.rate_limit_per_hour, + }) +} + +pub async fn init_local_api_for_user_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result<(), String> { + models::local_api::upsert_local_api_settings( + &svc_ctx.db, + user_uuid, + None, + None, + None, + None, + ) + .await + .map_err(|e| e.to_string())?; + + let current_key = models::local_api::fetch_active_api_key(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if let Some(api_key) = current_key { + if api_key.api_key.is_none() { + rotate_local_api_key_for_user(svc_ctx, user_uuid).await?; + return Ok(()); + } + ensure_default_permissions(svc_ctx, api_key.id).await?; + return Ok(()); + } + + let api_key = generate_api_key(); + let key_hash = models::local_api::hash_api_key(&api_key); + let key_prefix = api_key.chars().take(16).collect::(); + + let created_key = models::local_api::insert_api_key( + &svc_ctx.db, + user_uuid, + &key_prefix, + &key_hash, + &api_key, + DEFAULT_DAILY_LIMIT, + ) + .await + .map_err(|e| e.to_string())?; + set_local_api_key_cache( + svc_ctx, + &key_hash, + &LocalApiKeyCache { + id: created_key.id, + user_uuid: created_key.user_uuid, + is_active: created_key.is_active, + expires_at: created_key.expires_at, + daily_limit: created_key.daily_limit, + }, + ) + .await + .map_err(|e| e.to_string())?; + + ensure_default_permissions(svc_ctx, created_key.id).await +} + +async fn ensure_default_permissions(svc_ctx: &SvcCtx, api_key_id: i32) -> Result<(), String> { + let definitions = models::local_api::fetch_permission_definitions(&svc_ctx.db) + .await + .map_err(|e| e.to_string())?; + + for definition in definitions { + models::local_api::insert_api_key_permission( + &svc_ctx.db, + api_key_id, + definition.permission_code.as_str(), + definition.default_rate_limit_per_minute, + definition.default_rate_limit_per_hour, + ) + .await + .map_err(|e| e.to_string())?; + set_local_api_permission_definition_cache( + svc_ctx, + definition.permission_code.as_str(), + &definition, + ) + .await + .map_err(|e| e.to_string())?; + set_local_api_permission_cache( + svc_ctx, + api_key_id, + definition.permission_code.as_str(), + &LocalApiPermissionCache { + is_enabled: true, + rate_limit_per_minute: definition.default_rate_limit_per_minute, + rate_limit_per_hour: definition.default_rate_limit_per_hour, + }, + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +async fn fetch_effective_permission( + svc_ctx: &SvcCtx, + api_key_id: i32, + permission_code: &str, +) -> Result, String> { + models::local_api::fetch_api_key_permission(&svc_ctx.db, api_key_id, permission_code) + .await + .map_err(|e| e.to_string()) +} + +fn generate_api_key() -> String { + let raw = Uuid::new_v4().simple().to_string(); + format!("sk_local_{}", raw) +} + +async fn rotate_local_api_key_for_user( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + models::local_api::deactivate_api_keys_for_user(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let api_key = generate_api_key(); + let key_hash = models::local_api::hash_api_key(&api_key); + let key_prefix = api_key.chars().take(16).collect::(); + + let created_key = models::local_api::insert_api_key( + &svc_ctx.db, + user_uuid, + &key_prefix, + &key_hash, + &api_key, + DEFAULT_DAILY_LIMIT, + ) + .await + .map_err(|e| e.to_string())?; + set_local_api_key_cache( + svc_ctx, + &key_hash, + &LocalApiKeyCache { + id: created_key.id, + user_uuid: created_key.user_uuid, + is_active: created_key.is_active, + expires_at: created_key.expires_at, + daily_limit: created_key.daily_limit, + }, + ) + .await + .map_err(|e| e.to_string())?; + + ensure_default_permissions(svc_ctx, created_key.id).await?; + + Ok(ResetLocalApiKeyDto { api_key }) +} diff --git a/server/src/services/maintenance.rs b/server/src/services/maintenance.rs new file mode 100644 index 00000000..3862fe09 --- /dev/null +++ b/server/src/services/maintenance.rs @@ -0,0 +1,78 @@ +use crate::{ + dto::maintenance::Maintenance, entitys::maintenance::CreateMaintenanceRequest, + errors::SimprintError, svc_ctx::SvcCtx, +}; + +/// 创建维护 +pub async fn create_maintenance_service( + svc_ctx: &SvcCtx, + request: CreateMaintenanceRequest, +) -> Result { + let maintenance = crate::models::maintenance::create_maintenance(&svc_ctx.db, request) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("创建维护失败: {}", e)))?; + + Ok(maintenance.id) +} + +/// 根据ID查询维护 +pub async fn get_maintenance_by_id_service( + svc_ctx: &SvcCtx, + id: i64, +) -> Result, SimprintError> { + let maintenance = crate::models::maintenance::get_maintenance_by_id(&svc_ctx.db, id) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("查询维护失败: {}", e)))?; + + Ok(maintenance) +} + +/// 查询维护列表 +pub async fn list_maintenances_service( + svc_ctx: &SvcCtx, + limit: Option, + offset: Option, +) -> Result, SimprintError> { + let maintenances = crate::models::maintenance::list_maintenances( + &svc_ctx.db, + limit.map(|l| l as i64), + offset.map(|o| o as i64), + ) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("查询维护列表失败: {}", e)))?; + + Ok(maintenances) +} + +/// 更新维护状态 +pub async fn update_maintenance_status_service( + svc_ctx: &SvcCtx, + id: i64, + status: String, +) -> Result { + let success = crate::models::maintenance::update_maintenance_status(&svc_ctx.db, id, &status) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("更新维护状态失败: {}", e)))?; + + Ok(success) +} + +/// 结束维护 +pub async fn end_maintenance_service(svc_ctx: &SvcCtx) -> Result { + let success = crate::models::maintenance::end_maintenance(&svc_ctx.db) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("结束维护失败: {}", e)))?; + + Ok(success) +} + +/// 获取当前活跃维护 +pub async fn get_active_maintenance_service( + svc_ctx: &SvcCtx, +) -> Result, SimprintError> { + let maintenance = crate::models::maintenance::get_active_maintenances(&svc_ctx.db) + .await + .map_err(|e| SimprintError::InvalidRequest(format!("查询活跃维护失败: {}", e)))?; + + Ok(maintenance) +} diff --git a/server/src/services/messages.rs b/server/src/services/messages.rs new file mode 100644 index 00000000..a5cf2f57 --- /dev/null +++ b/server/src/services/messages.rs @@ -0,0 +1,187 @@ +use uuid::Uuid; + +// DTOs are used through entitys +use crate::entitys::{ + BatchMarkReadRequest, CreateMessageRequest, HandleMessageRequest, ListMessagesRequest, + MarkMessageReadRequest, MessageListResponse, MessageStatsResponse, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建消息 +pub async fn create_message_service( + svc_ctx: &SvcCtx, + sender_uuid: Option, + payload: &CreateMessageRequest, +) -> Result { + let priority = payload.priority.as_deref().unwrap_or("normal"); + + // 创建消息 + let message_uuid = models::create_message( + &svc_ctx.db, + sender_uuid, + &payload.message_type, + &payload.title, + payload.content.as_deref(), + &payload.recipient_type, + payload.related_type.as_deref(), + payload.related_uuid, + priority, + payload.metadata.clone(), + ) + .await + .map_err(|e| e.to_string())?; + + // 根据接收者类型添加接收者 + match payload.recipient_type.as_str() { + "single" | "multiple" => { + if payload.recipient_uuids.is_empty() { + return Err("接收者列表不能为空".to_string()); + } + + // 对于邀请类消息,设置 action_status 为 pending + let action_status = if payload.message_type == "team_invitation" { + Some("pending") + } else { + None + }; + + models::add_message_recipients( + &svc_ctx.db, + message_uuid, + &payload.recipient_uuids, + action_status, + ) + .await + .map_err(|e| e.to_string())?; + } + "team" => { + // 团队消息由数据库触发器自动分发,无需手动添加 + if payload.related_type.as_deref() != Some("team") || payload.related_uuid.is_none() { + return Err("团队消息必须指定 related_type='team' 和 related_uuid".to_string()); + } + } + "all" => { + // 系统广播消息,需要为所有用户创建关联记录 + // 这里可以通过应用层逻辑实现,或者使用数据库触发器 + // 暂时返回错误,提示需要特殊处理 + return Err("系统广播消息暂不支持".to_string()); + } + _ => { + return Err(format!("不支持的接收者类型: {}", payload.recipient_type)); + } + } + + Ok(message_uuid) +} + +/// 获取用户消息列表 +pub async fn get_user_messages_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListMessagesRequest, +) -> Result { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let filters = payload.filters.as_ref(); + + let messages = models::fetch_user_messages( + &svc_ctx.db, + user_uuid, + offset, + payload.pagination.page_size, + filters.and_then(|f| f.message_type.as_deref()), + filters.and_then(|f| f.is_read), + filters.and_then(|f| f.action_status.as_deref()), + filters.and_then(|f| f.priority.as_deref()), + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_user_messages_count( + &svc_ctx.db, + user_uuid, + filters.and_then(|f| f.message_type.as_deref()), + filters.and_then(|f| f.is_read), + filters.and_then(|f| f.action_status.as_deref()), + filters.and_then(|f| f.priority.as_deref()), + ) + .await + .map_err(|e| e.to_string())?; + + Ok(MessageListResponse { + items: messages, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }) +} + +/// 标记消息为已读 +pub async fn mark_message_read_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &MarkMessageReadRequest, +) -> Result<(), String> { + models::mark_message_read(&svc_ctx.db, payload.message_uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量标记消息为已读 +pub async fn batch_mark_messages_read_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &BatchMarkReadRequest, +) -> Result<(), String> { + if payload.message_uuids.is_empty() { + return Err("消息列表不能为空".to_string()); + } + + models::batch_mark_messages_read(&svc_ctx.db, &payload.message_uuids, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 处理消息(接受/拒绝) +pub async fn handle_message_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &HandleMessageRequest, +) -> Result<(), String> { + if payload.action != "accept" && payload.action != "reject" { + return Err("操作类型必须是 'accept' 或 'reject'".to_string()); + } + + models::handle_message( + &svc_ctx.db, + payload.message_uuid, + user_uuid, + &payload.action, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取用户消息统计 +pub async fn get_user_message_stats_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + let (total, unread, by_type) = models::fetch_user_message_stats(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok(MessageStatsResponse { + total, + unread, + by_type, + }) +} + +/// 删除消息 +pub async fn delete_message_service(svc_ctx: &SvcCtx, message_uuid: Uuid) -> Result<(), String> { + models::delete_message(&svc_ctx.db, message_uuid) + .await + .map_err(|e| e.to_string()) +} diff --git a/server/src/services/orders.rs b/server/src/services/orders.rs new file mode 100644 index 00000000..49d44b4a --- /dev/null +++ b/server/src/services/orders.rs @@ -0,0 +1,83 @@ +use uuid::Uuid; + +use crate::dto::PaymentOrderDto; +use crate::entitys::{CreateRechargeOrderRequest, ListPaymentOrdersRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建充值订单 +pub async fn create_recharge_order_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &CreateRechargeOrderRequest, +) -> Result<(Uuid, String), String> { + let order_no = format!("RCH-{}", chrono::Utc::now().format("%Y%m%d%H%M%S%f")); + + let order_uuid = models::billing::insert_payment_order( + &svc_ctx.db, + &order_no, + user_uuid, + "recharge", + payload.amount, + "CNY", + Some(&payload.payment_channel), + Some("钱包充值"), + None, + None, + None, + None, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((order_uuid, order_no)) +} + +/// 获取支付订单列表 +pub async fn get_payment_orders_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListPaymentOrdersRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let orders = models::billing::fetch_payment_orders( + &svc_ctx.db, + user_uuid, + payload.order_type.as_deref(), + payload.status.as_deref(), + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::billing::fetch_payment_orders_count( + &svc_ctx.db, + user_uuid, + payload.order_type.as_deref(), + payload.status.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + Ok((orders, total)) +} + +/// 查询订单状态 +pub async fn get_order_status_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + order_uuid: Uuid, +) -> Result { + let order = models::billing::fetch_payment_order_by_uuid(&svc_ctx.db, order_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "订单不存在".to_string())?; + + if order.user_uuid != user_uuid { + return Err("无权查看此订单".to_string()); + } + + Ok(order) +} diff --git a/server/src/services/plans.rs b/server/src/services/plans.rs new file mode 100644 index 00000000..be6f0815 --- /dev/null +++ b/server/src/services/plans.rs @@ -0,0 +1,152 @@ +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::dto::{CouponDto, PlanDto, PlanFeatureDto}; +use crate::entitys::{GetPlanPriceRequest, PlanPriceResponse, VerifyCouponRequest}; +use crate::models; +use crate::services::coupons::validate_coupon_service; +use crate::svc_ctx::SvcCtx; + +/// 带特性的套餐结构 +#[derive(Debug, Clone)] +pub struct PlanWithFeatures { + pub plan: PlanDto, + pub features: Vec, + pub calculated_price: Option, +} + +/// 获取套餐列表(包含特性) +pub async fn get_plans_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + coupon_code: Option<&str>, + billing_period: &str, +) -> Result, String> { + let plans = models::billing::fetch_plans(&svc_ctx.db).await.map_err(|e| e.to_string())?; + + let mut plans_with_features = Vec::new(); + + for plan in plans { + let features = models::billing::fetch_plan_features(&svc_ctx.db, plan.uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果提供了优惠券代码,计算价格 + let calculated_price = if let Some(code) = coupon_code { + let price_result = calculate_plan_price_service( + svc_ctx, + user_uuid, + &GetPlanPriceRequest { + plan_uuid: plan.uuid, + billing_period: billing_period.to_string(), + coupon_code: Some(code.to_string()), + }, + ) + .await + .ok(); // 如果计算失败,返回 None,不影响套餐列表返回 + + price_result.map(|price| crate::entitys::PlanPriceInfo { + original_price: price.original_price, + plan_discount: price.plan_discount, + coupon_discount: price.coupon_discount, + final_price: price.final_price, + total_saved: price.total_saved, + billing_period: billing_period.to_string(), + }) + } else { + None + }; + + plans_with_features.push(PlanWithFeatures { + plan, + features, + calculated_price, + }); + } + + Ok(plans_with_features) +} + +/// 获取套餐详情(包含特性) +pub async fn get_plan_detail_service( + svc_ctx: &SvcCtx, + plan_uuid: Uuid, +) -> Result<(PlanDto, Vec), String> { + let plan = models::billing::fetch_plan_by_uuid(&svc_ctx.db, plan_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "套餐不存在".to_string())?; + + let features = models::billing::fetch_plan_features(&svc_ctx.db, plan_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok((plan, features)) +} + +/// 计算套餐最终价格 +pub async fn calculate_plan_price_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &GetPlanPriceRequest, +) -> Result { + // 1. 获取套餐信息 + let plan = models::billing::fetch_plan_by_uuid(&svc_ctx.db, payload.plan_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "套餐不存在".to_string())?; + + if plan.status != "active" { + return Err("该套餐已下架".to_string()); + } + + // 2. 计算基础价格(考虑套餐级折扣) + let (base_price, plan_discount_percent) = match payload.billing_period.as_str() { + "monthly" => ( + plan.price_per_month, + plan.discount_monthly.unwrap_or(Decimal::ZERO), + ), + "yearly" => ( + plan.price_per_year, + plan.discount_yearly.unwrap_or(Decimal::ZERO), + ), + _ => return Err("无效的计费周期".to_string()), + }; + + // 3. 应用套餐级折扣 + let price_after_plan_discount = + base_price * (Decimal::from(100) - plan_discount_percent) / Decimal::from(100); + let plan_discount = base_price - price_after_plan_discount; + + // 4. 验证并应用优惠券(如果有) + let mut coupon_discount = Decimal::ZERO; + let mut coupon_info: Option = None; + + if let Some(coupon_code) = &payload.coupon_code { + let coupon_result = validate_coupon_service( + svc_ctx, + user_uuid, + &VerifyCouponRequest { + code: coupon_code.clone(), + amount: price_after_plan_discount, // 在套餐折扣后的价格上应用优惠券 + }, + ) + .await?; + + coupon_discount = coupon_result.discount_amount; + coupon_info = Some(coupon_result.coupon); + } + + // 5. 计算最终价格 + let final_price = price_after_plan_discount - coupon_discount; + let total_saved = plan_discount + coupon_discount; + + Ok(PlanPriceResponse { + original_price: base_price, + plan_discount, + coupon_discount, + final_price: final_price.max(Decimal::ZERO), // 确保价格不为负 + total_saved, + coupon_info, + }) +} diff --git a/server/src/services/preferences.rs b/server/src/services/preferences.rs new file mode 100644 index 00000000..eb08873f --- /dev/null +++ b/server/src/services/preferences.rs @@ -0,0 +1,49 @@ +use uuid::Uuid; + +use crate::dto::UserPreferenceDto; +use crate::entitys::settings::UpdatePreferencesRequest; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 获取用户偏好设置 +pub async fn get_preferences_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + let preferences = models::preferences::fetch_user_preferences(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果不存在,创建默认设置 + if preferences.is_none() { + models::preferences::upsert_user_preferences(&svc_ctx.db, user_uuid, None, None, None) + .await + .map_err(|e| e.to_string())?; + + return models::preferences::fetch_user_preferences(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "创建偏好设置失败".to_string()); + } + + preferences.ok_or_else(|| "偏好设置不存在".to_string()) +} + +/// 更新用户偏好设置 +pub async fn update_preferences_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdatePreferencesRequest, +) -> Result { + models::preferences::upsert_user_preferences( + &svc_ctx.db, + user_uuid, + payload.theme.as_deref(), + payload.language.as_deref(), + payload.notifications_enabled, + ) + .await + .map_err(|e| e.to_string())?; + + get_preferences_service(svc_ctx, user_uuid).await +} diff --git a/server/src/services/proxies.rs b/server/src/services/proxies.rs new file mode 100644 index 00000000..1e4e2542 --- /dev/null +++ b/server/src/services/proxies.rs @@ -0,0 +1,234 @@ +use uuid::Uuid; + +use crate::dto::ProxyDto; +use crate::entitys::{ + BatchImportProxiesRequest, CreateProxyRequest, ListProxiesRequest, UpdateProxyRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建代理 +pub async fn create_proxy_service( + svc_ctx: &SvcCtx, + owner_uuid: Uuid, + workspace_uuid: Uuid, + payload: &CreateProxyRequest, +) -> Result { + // 1. 检查工作空间代理配额是否充足 + let quota_available = models::check_quota(&svc_ctx.db, workspace_uuid, "proxies") + .await + .map_err(|e| e.to_string())?; + if !quota_available { + return Err("工作空间代理配额不足,无法创建新代理".to_string()); + } + + // 2. 创建代理 + let proxy_uuid = models::insert_proxy( + &svc_ctx.db, + workspace_uuid, + owner_uuid, + &payload.name, + &payload.host, + payload.port, + &payload.proxy_type, + payload.username.as_deref(), + payload.password.as_deref(), + payload.country.as_deref(), + payload.city.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + // 3. 更新工作空间配额(创建后增加使用数) + models::increment_used_proxies(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(proxy_uuid) +} + +/// 获取代理列表(根据可见性过滤) +pub async fn get_proxies_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + current_team_uuid: Option, + payload: &ListProxiesRequest, +) -> Result<(Vec, i64), String> { + let filters = payload.filters.as_ref(); + let keyword = filters + .and_then(|f| f.keyword.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let proxy_type = filters + .and_then(|f| f.proxy_type.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let status = filters + .and_then(|f| f.status.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let country = filters + .and_then(|f| f.country.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + + let page = payload.pagination.page.max(1); + let page_size = payload.pagination.page_size.max(1); + let offset = (page - 1) * page_size; + + let proxies = models::fetch_visible_proxies_for_user_paginated( + &svc_ctx.db, + workspace_uuid, + user_uuid, + current_team_uuid, + keyword, + proxy_type, + status, + country, + offset, + page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_visible_proxies_for_user_count( + &svc_ctx.db, + workspace_uuid, + user_uuid, + current_team_uuid, + keyword, + proxy_type, + status, + country, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((proxies, total)) +} + +/// 获取代理详情 +pub async fn get_proxy_service(svc_ctx: &SvcCtx, proxy_uuid: Uuid) -> Result { + models::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string()) +} + +/// 更新代理 +pub async fn update_proxy_service( + svc_ctx: &SvcCtx, + payload: &UpdateProxyRequest, +) -> Result<(), String> { + models::update_proxy( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.host.as_deref(), + payload.port, + payload.proxy_type.as_deref(), + payload.username.as_deref(), + payload.password.as_deref(), + payload.country.as_deref(), + payload.city.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除代理 +pub async fn delete_proxy_service(svc_ctx: &SvcCtx, proxy_uuid: Uuid) -> Result<(), String> { + // 1. 获取代理信息(用于获取 workspace_uuid) + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let workspace_uuid = proxy.workspace_uuid; + + // 2. 删除代理 + models::delete_proxy(&svc_ctx.db, proxy_uuid).await.map_err(|e| e.to_string())?; + + // 3. 更新工作空间配额(删除后减少使用数) + models::decrement_used_proxies(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 批量删除代理 +pub async fn batch_delete_proxies_service( + svc_ctx: &SvcCtx, + proxy_uuids: &[Uuid], +) -> Result { + models::batch_delete_proxies(&svc_ctx.db, proxy_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// 批量导入代理 +/// +/// 接收客户端已解析好的代理列表,直接保存到数据库 +pub async fn batch_import_proxies_service( + svc_ctx: &SvcCtx, + owner_uuid: Uuid, + workspace_uuid: Uuid, + payload: &BatchImportProxiesRequest, +) -> Result { + // 1. 检查工作空间代理配额是否充足(检查是否有足够配额导入所有代理) + let import_count = payload.proxies.len() as i32; + let quota = models::fetch_workspace_quota(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间配额不存在".to_string())?; + + if quota.used_proxies + import_count > quota.max_proxies { + return Err(format!( + "工作空间代理配额不足,当前已使用 {}/{},无法导入 {} 个代理", + quota.used_proxies, quota.max_proxies, import_count + )); + } + + let mut success_count = 0; + let mut failed_count = 0; + let mut errors: Vec = vec![]; + for (index, proxy) in payload.proxies.iter().enumerate() { + let result = models::insert_proxy( + &svc_ctx.db, + workspace_uuid, + owner_uuid, + &proxy.name, + &proxy.host, + proxy.port, + &proxy.proxy_type, + proxy.username.as_deref(), + proxy.password.as_deref(), + proxy.country.as_deref(), + proxy.city.as_deref(), + ) + .await; + + match result { + Ok(_) => { + success_count += 1; + // 更新配额(每成功导入一个代理就增加配额使用数) + if let Err(e) = models::increment_used_proxies(&svc_ctx.db, workspace_uuid, 1).await + { + errors.push(format!("第 {} 项导入成功但更新配额失败: {}", index + 1, e)); + } + } + Err(e) => { + failed_count += 1; + errors.push(format!("第 {} 项: {}", index + 1, e)); + } + } + } + + Ok(crate::entitys::BatchImportResponse { + success_count, + failed_count, + errors, + }) +} diff --git a/server/src/services/proxy_visibility.rs b/server/src/services/proxy_visibility.rs new file mode 100644 index 00000000..7c126fdb --- /dev/null +++ b/server/src/services/proxy_visibility.rs @@ -0,0 +1,143 @@ +use uuid::Uuid; + +use crate::dto::ProxyDto; +use crate::entitys::{ + BatchSetProxyVisibleRequest, ListVisibleProxiesRequest, + RemoveProxyVisibleRequest, SetProxyVisibleRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 设置代理对团队可见 +pub async fn set_proxy_visible_to_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &SetProxyVisibleRequest, +) -> Result<(), String> { + // 检查权限:只有代理所有者或工作空间所有者可以设置可见性 + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let is_owner = proxy.owner_uuid == user_uuid; + let is_workspace_owner = + models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if !is_owner && !is_workspace_owner { + return Err("只有代理所有者或工作空间所有者可以设置可见性".to_string()); + } + + // 获取团队的工作空间 + let team = models::fetch_team_by_uuid(&svc_ctx.db, payload.team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + + if team.workspace_uuid != proxy.workspace_uuid { + return Err("团队和工作空间不匹配".to_string()); + } + + models::insert_proxy_visible_team( + &svc_ctx.db, + payload.proxy_uuid, + proxy.workspace_uuid, + payload.team_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 移除代理对团队的可见性 +pub async fn remove_proxy_visible_from_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &RemoveProxyVisibleRequest, +) -> Result<(), String> { + // 检查权限:只有代理所有者或工作空间所有者可以移除可见性 + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let is_owner = proxy.owner_uuid == user_uuid; + let is_workspace_owner = + models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if !is_owner && !is_workspace_owner { + return Err("只有代理所有者或工作空间所有者可以移除可见性".to_string()); + } + + models::remove_proxy_visible_team(&svc_ctx.db, payload.proxy_uuid, payload.team_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量设置代理可见性 +pub async fn batch_set_proxy_visible_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &BatchSetProxyVisibleRequest, +) -> Result<(), String> { + // 检查权限 + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let is_owner = proxy.owner_uuid == user_uuid; + let is_workspace_owner = + models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if !is_owner && !is_workspace_owner { + return Err("只有代理所有者或工作空间所有者可以设置可见性".to_string()); + } + + // 批量设置可见性 + for team_uuid in &payload.team_uuids { + models::insert_proxy_visible_team( + &svc_ctx.db, + payload.proxy_uuid, + proxy.workspace_uuid, + *team_uuid, + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +/// 获取可见的代理列表 +pub async fn get_visible_proxies_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListVisibleProxiesRequest, +) -> Result, String> { + models::fetch_visible_proxies_for_user( + &svc_ctx.db, + payload.workspace_uuid, + user_uuid, + payload.team_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 检查代理可见性 +pub async fn check_proxy_visibility_service( + svc_ctx: &SvcCtx, + proxy_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result { + models::check_proxy_visibility(&svc_ctx.db, proxy_uuid, workspace_uuid, team_uuid) + .await + .map_err(|e| e.to_string()) +} diff --git a/server/src/services/referral.rs b/server/src/services/referral.rs new file mode 100644 index 00000000..9c7c5a0b --- /dev/null +++ b/server/src/services/referral.rs @@ -0,0 +1,420 @@ +use rust_decimal::prelude::ToPrimitive; +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::dto::{ + RedeemOptionDto, RedeemRecordDto, ReferralLinkDto, ReferralLinkTierDto, ReferralRewardDto, + ReferredUserItemDto, UserReferralPointsDto, +}; +use crate::entitys::{ + ListReferralRewardsRequest, ListReferredUsersRequest, Pagination, RedeemPointsRequest, + ReferralDashboardResponse, ReferralPointsSummary, ReferralStatsResponse, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; +use crate::services::subscriptions::get_current_subscription_service; + +/// 获取推广统计 +pub async fn get_referral_stats_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + // 获取基本统计数据 + let (total_referrals, paid_referrals, total_consumption, last_30_days_consumption) = + models::referral::fetch_referral_stats(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 获取总奖励积分 + let total_rewards = models::referral::fetch_total_reward_points(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 获取用户积分 + let points = models::referral::fetch_user_points(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + let available_points = points.map(|p| p.available_points).unwrap_or(0); + + // 获取当前层级和下一层级 + let current_tier = models::referral::fetch_tier_by_threshold(&svc_ctx.db, paid_referrals) + .await + .map_err(|e| e.to_string())?; + + let current_threshold = current_tier.as_ref().map(|t| t.unlock_threshold).unwrap_or(0); + let next_tier = models::referral::fetch_next_tier(&svc_ctx.db, current_threshold) + .await + .map_err(|e| e.to_string())?; + + // 计算升级进度 + let upgrade_progress = if let Some(ref next) = next_tier { + let progress = (paid_referrals - current_threshold) as f64 + / (next.unlock_threshold - current_threshold) as f64 + * 100.0; + progress as i32 + } else { + 100 + }; + + Ok(ReferralStatsResponse { + total_referrals, + paid_referrals, + total_consumption, + last_30_days_consumption, + total_rewards, + available_points, + current_tier, + next_tier, + upgrade_progress, + }) +} + +/// 获取推广链接层级配置 +pub async fn get_referral_tiers_service( + svc_ctx: &SvcCtx, +) -> Result, String> { + models::referral::fetch_referral_tiers(&svc_ctx.db) + .await + .map_err(|e| e.to_string()) +} + +/// 获取推广链接列表 +pub async fn get_referral_links_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result<(Vec, Option), String> { + // 根据当前统计数据计算已解锁层级,并同步更新该用户所有推广链接的 unlocked 状态 + // + // 解锁规则: + // - 对于 unlock_threshold <= paid_referrals 的层级视为已解锁 + // - 这些层级对应的推广链接(按 tier_uuid 关联)统一标记为 unlocked = TRUE + // - 其余链接统一标记为 unlocked = FALSE + let (_total_referrals, paid_referrals, _total_consumption, _last_30_days_consumption) = + models::referral::fetch_referral_stats(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let tiers = models::referral::fetch_referral_tiers(&svc_ctx.db) + .await + .map_err(|e| e.to_string())?; + + let unlocked_tier_uuids: Vec = tiers + .into_iter() + .filter(|t| t.unlock_threshold <= paid_referrals) + .map(|t| t.uuid) + .collect(); + + models::referral::update_referral_links_unlock_status_for_user( + &svc_ctx.db, + user_uuid, + &unlocked_tier_uuids, + ) + .await + .map_err(|e| e.to_string())?; + + let mut links = models::referral::fetch_user_referral_links(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let mut current_link = models::referral::fetch_current_referral_link(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果配置了推广链接前缀,则在返回前为每个链接拼接完整 URL + if let Some(prefix) = svc_ctx.config.app.referral_link_prefix.as_deref() { + let prefix = prefix.trim_end_matches('/'); + + for link in &mut links { + link.url = Some(format!("{prefix}?referral_code={}", link.code)); + } + + if let Some(ref mut cl) = current_link { + cl.url = Some(format!("{prefix}?referral_code={}", cl.code)); + } + } + + Ok((links, current_link)) +} + +/// 获取推广看板聚合数据 +pub async fn get_referral_dashboard_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + // 基础统计(含 available_points 和 total_rewards) + let stats = get_referral_stats_service(svc_ctx, user_uuid).await?; + + // 推广链接及当前链接 + let (links, current_link) = get_referral_links_service(svc_ctx, user_uuid).await?; + + // 层级配置 + let tiers = get_referral_tiers_service(svc_ctx).await?; + + // 读取 pending_points,用于前端展示“审核中”积分 + let pending_points = models::referral::fetch_user_points(&svc_ctx.db, user_uuid) + .await + .ok() + .flatten() + .map(|p| p.pending_points) + .unwrap_or(0); + + let points = ReferralPointsSummary { + available_points: stats.available_points, + pending_points, + total_rewards: stats.total_rewards, + }; + + Ok(ReferralDashboardResponse { + stats, + links, + current_link, + tiers, + points, + }) +} + +/// 计算最近 30 天推广带来的预估收益,以及与当前套餐价格的覆盖比例 +pub async fn get_referral_plan_summary_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + // 基于推广统计获取最近 30 天消费金额和当前层级 + let stats = get_referral_stats_service(svc_ctx, user_uuid).await?; + + let last_30_days_consumption = stats.last_30_days_consumption; + + // 使用当前层级的 reward_rate 估算可得收益(如果没有当前层级,则按 0 估算) + let reward_rate = stats + .current_tier + .as_ref() + .map(|tier| tier.reward_rate) + .unwrap_or(Decimal::ZERO); + + // reward_rate 以百分比存储,例如 10 表示 10% + let referral_value_last_30_days = + last_30_days_consumption * reward_rate / Decimal::from(100u32); + + // 获取当前订阅,估算月度套餐价格 + let current_subscription = + get_current_subscription_service(svc_ctx, user_uuid).await.unwrap_or(None); + + let current_plan_monthly_price = current_subscription.as_ref().map(|sub| sub.price); + + // 计算覆盖比例 + let coverage_ratio = current_plan_monthly_price.map(|price| { + if price.is_zero() { + Decimal::ZERO + } else { + (referral_value_last_30_days / price).max(Decimal::ZERO) + } + }); + + Ok(crate::entitys::ReferralPlanSummaryResponse { + referral_value_last_30_days, + current_plan_monthly_price, + coverage_ratio, + }) +} + +/// 切换当前推广链接 +pub async fn switch_referral_link_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + link_uuid: Uuid, +) -> Result<(), String> { + models::referral::switch_current_referral_link(&svc_ctx.db, user_uuid, link_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取奖励记录列表 +pub async fn get_referral_rewards_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListReferralRewardsRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let rewards = models::referral::fetch_referral_rewards( + &svc_ctx.db, + user_uuid, + payload.keyword.as_deref(), + payload.reward_type.as_deref(), + payload.status.as_deref(), + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::referral::fetch_referral_rewards_count( + &svc_ctx.db, + user_uuid, + payload.keyword.as_deref(), + payload.reward_type.as_deref(), + payload.status.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + Ok((rewards, total)) +} + +/// 获取被邀请用户列表 +pub async fn get_referred_users_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListReferredUsersRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let rows = models::referral::fetch_referred_users( + &svc_ctx.db, + user_uuid, + payload.keyword.as_deref(), + payload.status.as_deref(), + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let users: Vec = rows + .into_iter() + .map(|r| ReferredUserItemDto { + id: r.id.to_string(), + email: r.email, + registered_at: r.registered_at, + status: r.status, + total_consumption: r.total_consumption.and_then(|d| d.to_f64()).unwrap_or(0.0), + last_30_days_consumption: r + .last_30_days_consumption + .and_then(|d| d.to_f64()) + .unwrap_or(0.0), + link_id: r.link_uuid.map(|u| u.to_string()).unwrap_or_default(), + }) + .collect(); + + let total = models::referral::fetch_referred_users_count( + &svc_ctx.db, + user_uuid, + payload.keyword.as_deref(), + payload.status.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + Ok((users, total)) +} + +/// 获取用户积分 +pub async fn get_user_points_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + let points = models::referral::fetch_user_points(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果不存在则创建 + if points.is_none() { + models::referral::upsert_user_points(&svc_ctx.db, user_uuid, 0, 0, 0, 0) + .await + .map_err(|e| e.to_string())?; + + return models::referral::fetch_user_points(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "创建积分记录失败".to_string()); + } + + points.ok_or_else(|| "积分记录不存在".to_string()) +} + +/// 获取兑换选项 +pub async fn get_redeem_options_service(svc_ctx: &SvcCtx) -> Result, String> { + models::referral::fetch_redeem_options(&svc_ctx.db) + .await + .map_err(|e| e.to_string()) +} + +/// 执行积分兑换 +pub async fn redeem_points_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &RedeemPointsRequest, +) -> Result<(Uuid, i32, Decimal), String> { + // 获取兑换选项 + let option = models::referral::fetch_redeem_option_by_uuid(&svc_ctx.db, payload.option_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "兑换选项不存在".to_string())?; + + if option.status != "active" { + return Err("该兑换选项已下架".to_string()); + } + + // 检查积分是否满足最低要求 + if payload.points < option.points_required { + return Err(format!( + "积分不足,最低需要 {} 积分", + option.points_required + )); + } + + // 获取用户积分 + let points = models::referral::fetch_user_points(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "积分记录不存在".to_string())?; + + if points.available_points < payload.points { + return Err("可用积分不足".to_string()); + } + + // 计算兑换价值 + let value = Decimal::from(payload.points) / Decimal::from(option.exchange_rate); + + // 扣减积分 + models::referral::deduct_user_points(&svc_ctx.db, user_uuid, payload.points) + .await + .map_err(|e| e.to_string())?; + + // 创建兑换记录 + let record_uuid = models::referral::insert_redeem_record( + &svc_ctx.db, + user_uuid, + payload.option_uuid, + payload.points, + value, + option.currency.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + Ok((record_uuid, payload.points, value)) +} + +/// 获取兑换记录 +pub async fn get_redeem_records_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + pagination: &Pagination, +) -> Result<(Vec, i64), String> { + let offset = (pagination.page - 1) * pagination.page_size; + + let records = models::referral::fetch_redeem_records( + &svc_ctx.db, + user_uuid, + offset, + pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::referral::fetch_redeem_records_count(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok((records, total)) +} diff --git a/server/src/services/rpa.rs b/server/src/services/rpa.rs new file mode 100644 index 00000000..4b58e8e5 --- /dev/null +++ b/server/src/services/rpa.rs @@ -0,0 +1,354 @@ +use uuid::Uuid; + +use crate::dto::{RpaTaskDto, RpaTaskStepDto}; +use crate::entitys::{ + CreateRpaTaskRequest, DuplicateRpaTaskRequest, ListRpaTasksRequest, UpdateRpaTaskRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// List RPA tasks. +pub async fn get_rpa_tasks_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &ListRpaTasksRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + let trigger_type = payload.filters.as_ref().and_then(|f| f.trigger_type.as_deref()); + + let tasks = models::rpa::fetch_rpa_tasks( + &svc_ctx.db, + team_uuid, + user_uuid, + keyword, + status, + trigger_type, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = + models::rpa::fetch_rpa_tasks_count( + &svc_ctx.db, + team_uuid, + user_uuid, + keyword, + status, + trigger_type, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((tasks, total)) +} + +/// Get RPA task detail. +pub async fn get_rpa_task_service( + svc_ctx: &SvcCtx, + task_uuid: Uuid, +) -> Result<(RpaTaskDto, Vec, Vec), String> { + let task = models::rpa::fetch_rpa_task_by_uuid(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "RPA task not found".to_string())?; + + let steps = models::rpa::fetch_rpa_task_steps(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string())?; + + let environments = models::rpa::fetch_rpa_task_environments(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string())?; + + let environment_uuids: Vec = + environments.into_iter().map(|e| e.environment_uuid).collect(); + + Ok((task, steps, environment_uuids)) +} + +/// Create an RPA task. +pub async fn create_rpa_task_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateRpaTaskRequest, +) -> Result { + let tags_json = payload.tags.as_ref().map(|t| serde_json::json!(t)); + + let task_uuid = models::rpa::insert_rpa_task( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + tags_json.as_ref(), + &payload.trigger_type, + payload.schedule.as_deref(), + payload.cron_expression.as_deref(), + &payload.run_mode, + payload.retry_count, + payload.retry_interval, + payload.timeout, + payload.concurrency, + payload.stop_on_error, + payload.notify_on_complete, + payload.notify_on_error, + ) + .await + .map_err(|e| e.to_string())?; + + // Persist ordered steps. + if let Some(steps) = &payload.steps { + for (i, step) in steps.iter().enumerate() { + models::rpa::insert_rpa_task_step( + &svc_ctx.db, + task_uuid, + &step.step_type, + &step.name, + &step.config, + step.enabled, + step.position_x, + step.position_y, + Some(step.sort_order.unwrap_or(i as i32)), + step.next_step_uuid, + step.branch_config.as_ref(), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + // Persist environment bindings. + if let Some(env_uuids) = &payload.environment_uuids { + for (i, env_uuid) in env_uuids.iter().enumerate() { + models::rpa::insert_rpa_task_environment( + &svc_ctx.db, + task_uuid, + *env_uuid, + Some(i as i32), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + Ok(task_uuid) +} + +/// Update an RPA task. +pub async fn update_rpa_task_service( + svc_ctx: &SvcCtx, + payload: &UpdateRpaTaskRequest, +) -> Result<(), String> { + let tags_json = payload.tags.as_ref().map(|t| serde_json::json!(t)); + + models::rpa::update_rpa_task( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + tags_json.as_ref(), + payload.trigger_type.as_deref(), + payload.schedule.as_deref(), + payload.cron_expression.as_deref(), + payload.run_mode.as_deref(), + payload.retry_count, + payload.retry_interval, + payload.timeout, + payload.concurrency, + payload.stop_on_error, + payload.notify_on_complete, + payload.notify_on_error, + ) + .await + .map_err(|e| e.to_string())?; + + // Replace stored steps when the caller sends a full step list. + if let Some(steps) = &payload.steps { + // Remove previous step rows first. + models::rpa::delete_rpa_task_steps(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())?; + + // Insert the new step rows. + for (i, step) in steps.iter().enumerate() { + models::rpa::insert_rpa_task_step( + &svc_ctx.db, + payload.uuid, + &step.step_type, + &step.name, + &step.config, + step.enabled, + step.position_x, + step.position_y, + Some(step.sort_order.unwrap_or(i as i32)), + step.next_step_uuid, + step.branch_config.as_ref(), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + // Replace environment bindings when provided. + if let Some(env_uuids) = &payload.environment_uuids { + // Remove previous environment bindings. + models::rpa::delete_rpa_task_environments(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())?; + + // Insert the new environment bindings. + for (i, env_uuid) in env_uuids.iter().enumerate() { + models::rpa::insert_rpa_task_environment( + &svc_ctx.db, + payload.uuid, + *env_uuid, + Some(i as i32), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + Ok(()) +} + +/// Soft-delete an RPA task. +pub async fn delete_rpa_task_service(svc_ctx: &SvcCtx, task_uuid: Uuid) -> Result<(), String> { + models::rpa::delete_rpa_task(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// Soft-delete RPA tasks in batch. +pub async fn batch_delete_rpa_tasks_service( + svc_ctx: &SvcCtx, + task_uuids: &[Uuid], +) -> Result { + models::rpa::batch_delete_rpa_tasks(&svc_ctx.db, task_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// Duplicate an RPA task. +pub async fn duplicate_rpa_task_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &DuplicateRpaTaskRequest, +) -> Result { + // Load source task data. + let (task, steps, environment_uuids) = get_rpa_task_service(svc_ctx, payload.uuid).await?; + + let new_name = payload + .new_name + .clone() + .unwrap_or_else(|| format!("{} (copy)", task.name)); + + // Create duplicated task row. + let new_task_uuid = models::rpa::insert_rpa_task( + &svc_ctx.db, + user_uuid, + team_uuid, + &new_name, + task.description.as_deref(), + task.tags.as_ref(), + &task.trigger_type, + task.schedule.as_deref(), + task.cron_expression.as_deref(), + &task.run_mode, + task.retry_count, + task.retry_interval, + task.timeout, + task.concurrency, + task.stop_on_error, + task.notify_on_complete, + task.notify_on_error, + ) + .await + .map_err(|e| e.to_string())?; + + // Copy steps. + for step in steps { + models::rpa::insert_rpa_task_step( + &svc_ctx.db, + new_task_uuid, + &step.step_type, + &step.name, + &step.config, + step.enabled, + step.position_x, + step.position_y, + step.sort_order, + step.next_step_uuid, + step.branch_config.as_ref(), + ) + .await + .map_err(|e| e.to_string())?; + } + + // Copy environment bindings. + for (i, env_uuid) in environment_uuids.iter().enumerate() { + models::rpa::insert_rpa_task_environment( + &svc_ctx.db, + new_task_uuid, + *env_uuid, + Some(i as i32), + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(new_task_uuid) +} + +/// Export an RPA task. +pub async fn export_rpa_task_service( + svc_ctx: &SvcCtx, + task_uuid: Uuid, +) -> Result<(String, String), String> { + let (task, steps, environment_uuids) = get_rpa_task_service(svc_ctx, task_uuid).await?; + + let export_data = serde_json::json!({ + "name": task.name, + "description": task.description, + "tags": task.tags, + "trigger_type": task.trigger_type, + "schedule": task.schedule, + "cron_expression": task.cron_expression, + "run_mode": task.run_mode, + "retry_count": task.retry_count, + "retry_interval": task.retry_interval, + "timeout": task.timeout, + "concurrency": task.concurrency, + "stop_on_error": task.stop_on_error, + "notify_on_complete": task.notify_on_complete, + "notify_on_error": task.notify_on_error, + "steps": steps.iter().map(|s| serde_json::json!({ + "step_type": s.step_type, + "name": s.name, + "config": s.config, + "enabled": s.enabled, + "position_x": s.position_x, + "position_y": s.position_y, + "sort_order": s.sort_order, + "next_step_uuid": s.next_step_uuid, + "branch_config": s.branch_config, + })).collect::>(), + "environment_uuids": environment_uuids, + }); + + let content = serde_json::to_string_pretty(&export_data).map_err(|e| e.to_string())?; + let filename = format!("rpa_task_{}.json", task_uuid); + + Ok((content, filename)) +} + + + diff --git a/server/src/services/strategy_types.rs b/server/src/services/strategy_types.rs new file mode 100644 index 00000000..6aa9487b --- /dev/null +++ b/server/src/services/strategy_types.rs @@ -0,0 +1,50 @@ +use crate::{ + dto::strategy_types::StrategyType, entitys::strategy_types::*, errors::SimprintError, + svc_ctx::SvcCtx, +}; + +/// 根据ID查询策略类型 +pub async fn get_strategy_type_by_id( + svc_ctx: &SvcCtx, + id: i32, +) -> Result { + let strategy_type = crate::models::strategy_types::query_strategy_type_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::StrategyTypeNotFound)?; + + Ok(strategy_type) +} + +/// 根据code查询策略类型 +pub async fn get_strategy_type_by_code( + svc_ctx: &SvcCtx, + code: &str, +) -> Result { + let strategy_type = + crate::models::strategy_types::query_strategy_type_by_code(&svc_ctx.db, &code) + .await + .map_err(|_| SimprintError::StrategyTypeNotFound)?; + + Ok(strategy_type) +} + +/// 查询所有可用策略类型 +pub async fn query_available_strategy_types( + svc_ctx: &SvcCtx, +) -> Result { + let list = crate::models::strategy_types::query_available_strategy_types(&svc_ctx.db).await?; + + Ok(StrategyTypeListResponse { list }) +} + +/// 根据分类查询策略类型 +pub async fn query_strategy_types_by_category( + svc_ctx: &SvcCtx, + category: &str, +) -> Result { + let list = + crate::models::strategy_types::query_strategy_types_by_category(&svc_ctx.db, &category) + .await?; + + Ok(StrategyTypeListResponse { list }) +} diff --git a/server/src/services/subscriptions.rs b/server/src/services/subscriptions.rs new file mode 100644 index 00000000..4752ece8 --- /dev/null +++ b/server/src/services/subscriptions.rs @@ -0,0 +1,275 @@ +use chrono::Utc; +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::dto::SubscriptionDto; +use crate::entitys::{SubscribePlanRequest, VerifyCouponRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +use super::coupons::validate_coupon_service; +use super::wallet::deduct_wallet_service; +use crate::models::workspace_quotas; + +/// 获取当前订阅(按用户) +pub async fn get_current_subscription_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::billing::fetch_active_subscription(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取工作空间当前订阅 +pub async fn get_workspace_subscription_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, +) -> Result, String> { + models::billing::fetch_workspace_active_subscription(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 订阅套餐 +pub async fn subscribe_plan_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, + payload: &SubscribePlanRequest, +) -> Result { + // 1. 获取套餐信息 + let plan = models::billing::fetch_plan_by_uuid(&svc_ctx.db, payload.plan_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "套餐不存在".to_string())?; + + if plan.status != "active" { + return Err("该套餐已下架".to_string()); + } + + // 2. 计算基础价格和套餐折扣 + let (base_price, plan_discount_percent, duration_days) = match payload.billing_period.as_str() { + "monthly" => ( + plan.price_per_month, + plan.discount_monthly.unwrap_or(Decimal::ZERO), + 30, + ), + "yearly" => ( + plan.price_per_year, + plan.discount_yearly.unwrap_or(Decimal::ZERO), + 365, + ), + _ => return Err("无效的计费周期".to_string()), + }; + + // 应用套餐级折扣 + let price_after_plan_discount = + base_price * (Decimal::from(100) - plan_discount_percent) / Decimal::from(100); + + // 3. 验证优惠券(如果有) + let mut final_price = price_after_plan_discount; + let mut coupon_uuid: Option = None; + let mut coupon_discount_amount = Decimal::ZERO; + + if let Some(coupon_code) = &payload.coupon_code { + let coupon_result = validate_coupon_service( + svc_ctx, + user_uuid, + &VerifyCouponRequest { + code: coupon_code.clone(), + amount: price_after_plan_discount, // 在套餐折扣后的价格上应用优惠券 + }, + ) + .await?; + + coupon_uuid = Some(coupon_result.coupon.uuid); + coupon_discount_amount = coupon_result.discount_amount; + final_price = price_after_plan_discount - coupon_discount_amount; + } + + // 4. 检查支付方式并验证钱包余额(仅钱包支付需要检查) + let payment_method = payload.payment_method.as_deref().unwrap_or("wallet"); + let is_wallet_payment = payment_method == "wallet"; + + if is_wallet_payment { + let wallet = models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let wallet_balance = wallet.map(|w| w.balance).unwrap_or(Decimal::ZERO); + if wallet_balance < final_price { + return Err("钱包余额不足,请先充值".to_string()); + } + } + + // 5. 取消工作空间现有订阅 + if let Some(existing) = models::billing::fetch_workspace_active_subscription(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + { + models::billing::cancel_subscription(&svc_ctx.db, existing.uuid) + .await + .map_err(|e| e.to_string())?; + } + + // 6. 计算到期时间 + let expires_at = Utc::now() + chrono::Duration::days(duration_days); + let next_billing_date = expires_at.date_naive(); + + // 7. 创建订阅 + let subscription_uuid = models::billing::insert_subscription( + &svc_ctx.db, + workspace_uuid, + user_uuid, + payload.plan_uuid, + &payload.billing_period, + final_price, + &plan.currency, + expires_at, + next_billing_date, + ) + .await + .map_err(|e| e.to_string())?; + + // 7.1. 记录优惠券使用(如果有) + if let Some(coupon_uuid_val) = coupon_uuid { + // 记录优惠券使用 + models::billing::insert_coupon_usage( + &svc_ctx.db, + coupon_uuid_val, + user_uuid, + Some(subscription_uuid), + coupon_discount_amount, + ) + .await + .map_err(|e| e.to_string())?; + + // 更新用户优惠券状态为 used(如果是从 user_coupons 表发放的) + models::billing::update_user_coupon_status( + &svc_ctx.db, + user_uuid, + coupon_uuid_val, + "used", + Some(Utc::now()), + ) + .await + .map_err(|e| e.to_string())?; + } + + // 8. 扣减钱包余额(仅钱包支付需要扣减) + if is_wallet_payment { + deduct_wallet_service( + svc_ctx, + user_uuid, + final_price, + "订阅套餐", + Some(subscription_uuid), + ) + .await?; + } + + // 9. 更新工作空间配额 + workspace_quotas::insert_or_update_workspace_quota( + &svc_ctx.db, + workspace_uuid, + plan.max_environments, + plan.max_team_members, + plan.max_proxies, + plan.max_rpa_tasks, + ) + .await + .map_err(|e| e.to_string())?; + + // 10. 创建发票 + let invoice_number = format!("INV-{}", chrono::Utc::now().format("%Y%m%d%H%M%S")); + models::billing::insert_invoice( + &svc_ctx.db, + user_uuid, + &invoice_number, + final_price, + &plan.currency, + Some(subscription_uuid), + None, + "subscription", + ) + .await + .map_err(|e| e.to_string())?; + + Ok(subscription_uuid) +} + +/// 取消订阅 +pub async fn cancel_subscription_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + subscription_uuid: Uuid, +) -> Result<(), String> { + // 验证订阅属于当前用户 + let subscription = models::billing::fetch_subscription_by_uuid(&svc_ctx.db, subscription_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "订阅不存在".to_string())?; + + if subscription.user_uuid != user_uuid { + return Err("无权操作此订阅".to_string()); + } + + if subscription.status == "cancelled" { + return Err("订阅已取消".to_string()); + } + + models::billing::cancel_subscription(&svc_ctx.db, subscription_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 恢复订阅 +pub async fn resume_subscription_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + subscription_uuid: Uuid, +) -> Result<(), String> { + let subscription = models::billing::fetch_subscription_by_uuid(&svc_ctx.db, subscription_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "订阅不存在".to_string())?; + + if subscription.user_uuid != user_uuid { + return Err("无权操作此订阅".to_string()); + } + + if subscription.status != "cancelled" { + return Err("只能恢复已取消的订阅".to_string()); + } + + // 检查是否过期 + if subscription.expires_at < Utc::now() { + return Err("订阅已过期,请重新订阅".to_string()); + } + + models::billing::resume_subscription(&svc_ctx.db, subscription_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 切换自动续费 +pub async fn toggle_auto_renew_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + subscription_uuid: Uuid, + auto_renew: bool, +) -> Result<(), String> { + let subscription = models::billing::fetch_subscription_by_uuid(&svc_ctx.db, subscription_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "订阅不存在".to_string())?; + + if subscription.user_uuid != user_uuid { + return Err("无权操作此订阅".to_string()); + } + + models::billing::toggle_auto_renew(&svc_ctx.db, subscription_uuid, auto_renew) + .await + .map_err(|e| e.to_string()) +} diff --git a/server/src/services/tags.rs b/server/src/services/tags.rs new file mode 100644 index 00000000..0f782a8b --- /dev/null +++ b/server/src/services/tags.rs @@ -0,0 +1,64 @@ +use uuid::Uuid; + +use crate::dto::TagDto; +use crate::entitys::tags::{CreateTagRequest, UpdateTagRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建标签 +pub async fn create_tag_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateTagRequest, +) -> Result { + models::insert_tag( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.name, + payload.color.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取标签列表 +pub async fn get_tags_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, +) -> Result, String> { + models::fetch_tags(&svc_ctx.db, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取标签详情 +pub async fn get_tag_service(svc_ctx: &SvcCtx, tag_uuid: Uuid) -> Result { + models::fetch_tag_by_uuid(&svc_ctx.db, tag_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "标签不存在".to_string()) +} + +/// 更新标签 +pub async fn update_tag_service( + svc_ctx: &SvcCtx, + payload: &UpdateTagRequest, +) -> Result<(), String> { + models::update_tag( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.color.as_deref(), + payload.sort_order, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除标签 +pub async fn delete_tag_service(svc_ctx: &SvcCtx, tag_uuid: Uuid) -> Result<(), String> { + models::delete_tag(&svc_ctx.db, tag_uuid).await.map_err(|e| e.to_string()) +} diff --git a/server/src/services/teams.rs b/server/src/services/teams.rs new file mode 100644 index 00000000..0859ef1b --- /dev/null +++ b/server/src/services/teams.rs @@ -0,0 +1,495 @@ +use uuid::Uuid; + +use crate::dto::{TeamDto, TeamInvitationDto, TeamMemberDto}; +use crate::entitys::{ + CreateTeamRequest, InviteMemberRequest, ListTeamMembersRequest, SwitchTeamRequest, + UpdateMemberRoleRequest, UpdateTeamRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建团队 +pub async fn create_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &CreateTeamRequest, +) -> Result { + models::insert_team(&svc_ctx.db, user_uuid, payload) + .await + .map_err(|e| e.to_string()) +} + +/// 获取团队详情 +pub async fn get_team_service(svc_ctx: &SvcCtx, team_uuid: Uuid) -> Result { + models::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string()) +} + +/// 获取用户所属的所有团队(工作空间级别) +pub async fn get_user_teams_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result, String> { + models::fetch_user_teams(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取用户当前团队 +pub async fn get_current_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::fetch_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 切换团队 +pub async fn switch_team_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, + payload: &SwitchTeamRequest, +) -> Result<(), String> { + // 验证用户是否是团队成员(工作空间级别) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, payload.team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if member.is_none() { + return Err("您不是该团队的成员".to_string()); + } + + models::set_user_current_team(&svc_ctx.db, user_uuid, payload.team_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 更新团队信息 +pub async fn update_team_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, + payload: &UpdateTeamRequest, +) -> Result<(), String> { + // 检查权限(仅所有者和管理员可以更新) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, payload.uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if member.role != "owner" && member.role != "admin" { + return Err("权限不足".to_string()); + } + + models::update_team( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.avatar_hash.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取团队成员列表 +pub async fn get_team_members_service( + svc_ctx: &SvcCtx, + team_uuid: Uuid, + payload: &ListTeamMembersRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + // 提取筛选条件 + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let role = payload.filters.as_ref().and_then(|f| f.role.as_deref()); + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + + let members = models::fetch_team_members( + &svc_ctx.db, + team_uuid, + offset, + payload.pagination.page_size, + keyword, + role, + status, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_team_member_count(&svc_ctx.db, team_uuid, keyword, role, status) + .await + .map_err(|e| e.to_string())?; + + Ok((members, total)) +} + +/// 邀请成员 +pub async fn invite_member_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + inviter_uuid: Uuid, + payload: &InviteMemberRequest, +) -> Result { + // 检查邀请者权限(工作空间级别) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, inviter_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if member.role != "owner" && member.role != "admin" { + return Err("权限不足".to_string()); + } + + // 检查是否已有待处理的邀请 + if models::has_pending_invitation(&svc_ctx.db, team_uuid, &payload.email) + .await + .map_err(|e| e.to_string())? + { + return Err("该邮箱已有待处理的邀请".to_string()); + } + + // 检查用户是否已是成员 + let invited_user_info = + crate::models::user::fetch_user_info_by_email(&svc_ctx.db, &payload.email) + .await + .map_err(|e| e.to_string())?; + + // 检查用户是否存在 + let user_info = invited_user_info.ok_or_else(|| "该邮箱未注册,请先注册账号".to_string())?; + + // 检查用户是否已是团队成员 + if models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_info.user_uuid) + .await + .map_err(|e| e.to_string())? + .is_some() + { + return Err("该用户已是团队成员".to_string()); + } + + // 获取团队信息 + let team = models::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + + // 获取邀请者信息 + let inviter_info = crate::models::user::fetch_user_info_by_uuid(&svc_ctx.db, inviter_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "邀请者信息不存在".to_string())?; + + // 生成邀请 token + let token = uuid::Uuid::new_v4().to_string(); + let expires_at = chrono::Utc::now() + chrono::Duration::days(7); + + // 插入邀请记录 + let invitation_uuid = models::insert_team_invitation( + &svc_ctx.db, + team_uuid, + &payload.email, + &payload.role, + inviter_uuid, + &token, + expires_at, + ) + .await + .map_err(|e| e.to_string())?; + + // 1. 发送邮件通知 + let smtp_config = svc_ctx.config.clone().smtp.ok_or_else(|| "邮箱配置不存在".to_string())?; + + let inviter_name: &str = + inviter_info.nickname.as_deref().unwrap_or_else(|| inviter_info.email.as_str()); + let inviter_email: &str = inviter_info.email.as_str(); + + let email_title = format!("团队邀请:{} 邀请您加入团队", inviter_name); + let email_body = format!( + "

团队邀请

您好,

{}({})邀请您加入团队 {}

邀请角色:{}

邀请有效期:7 天

接受邀请

如果您不想接受此邀请,可以忽略此邮件。

", + inviter_name, inviter_email, team.name, payload.role + ); + + // 发送邮件(失败不影响邀请流程) + let _ = crate::utils::send_email( + &smtp_config.smtp_username, + &smtp_config.smtp_password, + &smtp_config.smtp_server, + &payload.email, + &email_title, + &email_body, + ); + + // 2. 如果用户已注册,发送团队邀请消息通知 + let _ = crate::services::messages::create_message_service( + svc_ctx, + Some(inviter_uuid), + &crate::entitys::CreateMessageRequest { + message_type: "team_invitation".to_string(), + title: format!("{} 邀请您加入团队 {}", inviter_name, team.name), + content: Some(format!( + "{}({})邀请您加入团队 {},角色:{}", + inviter_name, inviter_email, team.name, payload.role + )), + recipient_uuids: vec![user_info.user_uuid], + recipient_type: "single".to_string(), + related_type: Some("team".to_string()), + related_uuid: Some(team_uuid), + priority: Some("normal".to_string()), + metadata: Some(serde_json::json!({ + "invitation_uuid": invitation_uuid.to_string(), + "team_name": team.name, + "role": payload.role, + "inviter_name": inviter_name, + "inviter_email": inviter_email, + "token": token, + })), + }, + ) + .await; + // 消息创建失败不影响邀请流程,使用 _ 忽略错误 + + Ok(invitation_uuid) +} + +/// 取消邀请 +pub async fn cancel_invitation_service( + svc_ctx: &SvcCtx, + invitation_uuid: Uuid, +) -> Result<(), String> { + models::cancel_team_invitation(&svc_ctx.db, invitation_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 接受邀请 +pub async fn accept_invitation_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + token: &str, +) -> Result { + // 查找邀请 + let invitation = models::fetch_team_invitation_by_token(&svc_ctx.db, token) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "邀请不存在或已过期".to_string())?; + + // 获取被邀请用户的当前工作空间 + let user_workspace_uuid = crate::models::user::fetch_user_current_workspace(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "请先选择工作空间".to_string())?; + + // 检查用户是否已经是该工作空间的团队成员(如果是,则不需要检查配额) + let existing_member = models::fetch_team_member( + &svc_ctx.db, + user_workspace_uuid, + invitation.team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())?; + + // 如果用户还不是该工作空间的任何团队的成员,需要检查配额 + if existing_member.is_none() { + // 检查工作空间成员配额是否充足 + let quota_available = models::check_quota(&svc_ctx.db, user_workspace_uuid, "team_members") + .await + .map_err(|e| e.to_string())?; + if !quota_available { + return Err("工作空间成员配额不足,无法接受邀请".to_string()); + } + } + + // 使用 accept_team_invitation 函数处理整个流程 + let team_uuid = models::accept_team_invitation(&svc_ctx.db, invitation.uuid, user_uuid, user_workspace_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新成员配额(重新计算所有团队的活跃成员总数) + // 注意:只有在用户之前不是该工作空间的成员时才需要更新配额 + if existing_member.is_none() { + models::update_used_team_members(&svc_ctx.db, user_workspace_uuid) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + } + + Ok(team_uuid) +} + +/// 拒绝邀请 +pub async fn reject_invitation_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + token: &str, +) -> Result<(), String> { + // 查找邀请 + let invitation = models::fetch_team_invitation_by_token(&svc_ctx.db, token) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "邀请不存在或已过期".to_string())?; + + // 使用 reject_team_invitation 函数处理整个流程 + models::reject_team_invitation(&svc_ctx.db, invitation.uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 更新成员角色 +pub async fn update_member_role_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + operator_uuid: Uuid, + payload: &UpdateMemberRoleRequest, +) -> Result { + // 检查操作者权限(工作空间级别) + let operator = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, operator_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if operator.role != "owner" && operator.role != "admin" { + return Err("权限不足".to_string()); + } + + // 不能修改所有者角色 + let target = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, payload.member_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "成员不存在".to_string())?; + + if target.role == "owner" { + return Err("不能修改所有者角色".to_string()); + } + + // 管理员不能设置其他管理员 + if operator.role == "admin" && payload.role == "admin" { + return Err("管理员不能设置其他管理员".to_string()); + } + + models::update_member_role(&svc_ctx.db, team_uuid, payload.member_uuid, &payload.role) + .await + .map_err(|e| e.to_string())?; + + // 返回更新后的成员信息 + models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, payload.member_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "获取更新后的成员信息失败".to_string()) +} + +/// 移除成员 +pub async fn remove_member_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + operator_uuid: Uuid, + member_uuid: Uuid, +) -> Result<(), String> { + // 检查操作者权限(工作空间级别) + let operator = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, operator_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if operator.role != "owner" && operator.role != "admin" { + return Err("权限不足".to_string()); + } + + // 不能移除所有者 + let target = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, member_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "成员不存在".to_string())?; + + if target.role == "owner" { + return Err("不能移除所有者".to_string()); + } + + // 管理员不能移除其他管理员 + if operator.role == "admin" && target.role == "admin" { + return Err("管理员不能移除其他管理员".to_string()); + } + + models::remove_team_member(&svc_ctx.db, team_uuid, member_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新成员配额(重新计算所有团队的活跃成员总数) + models::update_used_team_members(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 退出团队 +pub async fn leave_team_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), String> { + // 检查用户是否是团队成员(工作空间级别) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 所有者不能退出团队 + if member.role == "owner" { + return Err("团队所有者不能退出团队,请先转移所有权或解散团队".to_string()); + } + + // 移除成员 + models::remove_team_member(&svc_ctx.db, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新成员配额(重新计算所有团队的活跃成员总数) + models::update_used_team_members(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + // 如果当前团队是用户的活跃团队,则需要切换到其他团队 + let current_team = models::fetch_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if current_team == Some(team_uuid) { + // 获取用户的其他团队(工作空间级别) + let teams = models::fetch_user_teams(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if let Some(first_team) = teams.first() { + // 切换到第一个可用团队 + models::set_user_current_team(&svc_ctx.db, user_uuid, first_team.uuid) + .await + .map_err(|e| e.to_string())?; + } else { + // 没有其他团队,清除当前团队 + models::clear_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + } + } + + Ok(()) +} + +/// 获取待处理邀请列表 +pub async fn get_pending_invitations_service( + svc_ctx: &SvcCtx, + team_uuid: Uuid, +) -> Result, String> { + models::fetch_pending_invitations(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string()) +} diff --git a/server/src/services/templates.rs b/server/src/services/templates.rs new file mode 100644 index 00000000..27f9d12b --- /dev/null +++ b/server/src/services/templates.rs @@ -0,0 +1,381 @@ +use uuid::Uuid; + +use crate::dto::TemplateDto; +use crate::entitys::{ + ApplyTemplateRequest, AssociationsStatus, CreateFromTemplateRequest, CreateTemplateRequest, + TemplateDetailResponse, UpdateTemplateRequest, +}; +use crate::models; +use crate::services; +use crate::svc_ctx::SvcCtx; + +/// 创建模板 +pub async fn create_template_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateTemplateRequest, +) -> Result { + // 确定要存储的完整数据 + let environment_data_json: serde_json::Value = if let Some(env_uuid) = payload.environment_uuid + { + // 如果提供了环境 UUID,先查询环境获取 workspace_uuid 和 team_uuid + let env = models::fetch_environment_by_uuid_unfiltered(&svc_ctx.db, env_uuid) + .await + .map_err(|e| format!("查询环境失败: {}", e))? + .ok_or_else(|| "环境不存在".to_string())?; + + // 获取完整的环境详情(带权限检查) + let env_detail = services::environments::get_environment_detail_service( + svc_ctx, + env.workspace_uuid, + env.team_uuid, + user_uuid, + env_uuid, + ) + .await + .map_err(|e| format!("获取环境详情失败: {}", e))?; + serde_json::to_value(&env_detail).map_err(|e| format!("序列化环境详情失败: {}", e))? + } else if let Some(ref env_data) = payload.environment_data { + // 如果直接提供了环境详情数据,使用它 + env_data.clone() + } else { + return Err("必须提供 environment_uuid 或 environment_data 之一".to_string()); + }; + + // 从环境数据中提取摘要信息(从标准结构 config.window_info 中提取) + let system_info = environment_data_json + .get("config") + .and_then(|v| v.get("window_info")) + .and_then(|v| v.get("system")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let kernel_info = environment_data_json + .get("config") + .and_then(|v| v.get("window_info")) + .and_then(|v| v.get("kernel")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + models::insert_template( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + payload.is_public.unwrap_or(false), + system_info.as_deref(), + kernel_info.as_deref(), + &environment_data_json, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取模板列表 +pub async fn get_templates_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + is_public: Option, + page: i64, + page_size: i64, +) -> Result<(Vec, i64), String> { + let offset = (page - 1) * page_size; + + let templates = models::fetch_templates( + &svc_ctx.db, + team_uuid, + user_uuid, + is_public, + offset, + page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_templates_count(&svc_ctx.db, team_uuid, user_uuid, is_public) + .await + .map_err(|e| e.to_string())?; + + Ok((templates, total)) +} + +/// 获取模板详情 +pub async fn get_template_service( + svc_ctx: &SvcCtx, + template_uuid: Uuid, + for_create: bool, +) -> Result { + let template = models::fetch_template_by_uuid(&svc_ctx.db, template_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "模板不存在".to_string())?; + + // 如果 for_create 为 true,检查关联数据是否存在 + let associations_status = if for_create { + Some(check_template_associations(svc_ctx, &template).await?) + } else { + None + }; + + Ok(TemplateDetailResponse { + template, + associations_status, + }) +} + +/// 检查模板关联数据是否存在 +async fn check_template_associations( + svc_ctx: &SvcCtx, + template: &TemplateDto, +) -> Result { + // 解析模板中的环境详情数据 + let env_detail: crate::entitys::EnvironmentDetailResponse = + serde_json::from_value(template.config_json.clone()) + .map_err(|e| format!("解析模板数据失败: {}", e))?; + + // 检查分组是否存在 + let group_exists = if let Some(group_uuid) = env_detail.environment.group_uuid { + models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .is_some() + } else { + false + }; + + // 检查标签是否存在 + let mut tags_exist = std::collections::HashMap::new(); + for tag in &env_detail.tags { + let exists = models::fetch_tag_by_uuid(&svc_ctx.db, tag.uuid) + .await + .map_err(|e| e.to_string())? + .is_some(); + tags_exist.insert(tag.uuid, exists); + } + + // 检查账号是否存在 + let mut accounts_exist = std::collections::HashMap::new(); + for account in &env_detail.accounts { + let exists = models::fetch_platform_account_by_uuid(&svc_ctx.db, account.uuid) + .await + .map_err(|e| e.to_string())? + .is_some(); + accounts_exist.insert(account.uuid, exists); + } + + // 检查代理是否存在 + let proxy_exists = if let Some(proxy_uuid) = env_detail.environment.proxy_uuid { + models::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .is_some() + } else { + false + }; + + Ok(AssociationsStatus { + group_exists, + tags_exist, + accounts_exist, + proxy_exists, + }) +} + +/// 更新模板 +pub async fn update_template_service( + svc_ctx: &SvcCtx, + payload: &UpdateTemplateRequest, +) -> Result<(), String> { + models::update_template( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.is_public, + payload.config_json.as_ref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除模板 +pub async fn delete_template_service(svc_ctx: &SvcCtx, template_uuid: Uuid) -> Result<(), String> { + models::delete_template(&svc_ctx.db, template_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 应用模板到现有环境(更新环境配置) +pub async fn apply_template_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &ApplyTemplateRequest, +) -> Result<(), String> { + // 获取模板配置(不需要检查关联数据) + let template_response = get_template_service(svc_ctx, payload.template_uuid, false).await?; + let template = &template_response.template; + + // 解析模板中的环境详情数据 + // 先转换为字符串再反序列化,确保类型正确 + let env_detail: crate::entitys::EnvironmentDetailResponse = serde_json::from_str( + &serde_json::to_string(&template.config_json) + .map_err(|e| format!("序列化模板数据失败: {}", e))?, + ) + .map_err(|e| format!("解析模板数据失败: {}", e))?; + + // 更新环境配置 + let update_req = crate::entitys::UpdateEnvironmentRequest { + uuid: payload.environment_uuid, + name: None, + description: None, + group_uuid: None, + cookies: Some( + env_detail + .cookies + .iter() + .map(|item| crate::entitys::CookieGroupInput { + site: item.site.clone(), + cookie_text: item.cookie_text.clone(), + }) + .collect(), + ), + urls: Some( + env_detail + .urls + .iter() + .map(|item| crate::entitys::UrlInput { + url: item.url.clone(), + title: item.title.clone(), + sort_order: item.sort_order, + }) + .collect(), + ), + config: env_detail.config.map(|config| crate::entitys::EnvironmentConfigRequest { + window_info: config.window_info, + basic_settings: config.basic_settings, + fingerprint_settings: config.fingerprint_settings, + device_settings: config.device_settings, + preference_settings: config.preference_settings, + project_metadata: config.project_metadata, + }), + }; + + services::environments::update_environment_service( + svc_ctx, + workspace_uuid, + team_uuid, + user_uuid, + &update_req, + ) + .await + .map_err(|e| e.to_string())?; + + // 增加模板使用次数 + let _ = models::increment_template_usage(&svc_ctx.db, payload.template_uuid).await; + + Ok(()) +} + +/// 从模板创建环境 +pub async fn create_from_template_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &CreateFromTemplateRequest, +) -> Result { + // 获取模板(不需要检查关联数据,因为这是直接创建,不经过前端表单) + let template_response = get_template_service(svc_ctx, payload.template_uuid, false).await?; + let template = &template_response.template; + + // 解析模板中的环境详情数据 + // 先转换为字符串再反序列化,确保类型正确 + let env_detail: crate::entitys::EnvironmentDetailResponse = serde_json::from_str( + &serde_json::to_string(&template.config_json) + .map_err(|e| format!("序列化模板数据失败: {}", e))?, + ) + .map_err(|e| format!("解析模板数据失败: {}", e))?; + + // 使用提供的参数覆盖模板中的值 + let env_name = payload.name.as_deref().unwrap_or(&env_detail.environment.name); + let env_description = + payload.description.as_ref().or(env_detail.environment.description.as_ref()); + let group_uuid = payload.group_uuid.or(env_detail.environment.group_uuid); + let proxy_uuid = env_detail.environment.proxy_uuid; + + // 提取标签 UUIDs + let tag_uuids: Vec = env_detail.tags.iter().map(|tag| tag.uuid).collect(); + + // 提取账号 UUIDs + let account_uuids: Vec = env_detail.accounts.iter().map(|acc| acc.uuid).collect(); + + // 构建创建环境请求 + let config = env_detail.config.ok_or_else(|| "模板中缺少配置信息".to_string())?; + + let create_req = crate::entitys::CreateEnvironmentRequest { + name: env_name.to_string(), + description: env_description.cloned(), + group_uuid, + tag_uuids: if tag_uuids.is_empty() { + None + } else { + Some(tag_uuids) + }, + account_uuids: if account_uuids.is_empty() { + None + } else { + Some(account_uuids) + }, + proxy_uuid, + cookies: Some( + env_detail + .cookies + .iter() + .map(|item| crate::entitys::CookieGroupInput { + site: item.site.clone(), + cookie_text: item.cookie_text.clone(), + }) + .collect(), + ), + urls: Some( + env_detail + .urls + .iter() + .map(|item| crate::entitys::UrlInput { + url: item.url.clone(), + title: item.title.clone(), + sort_order: item.sort_order, + }) + .collect(), + ), + config: crate::entitys::EnvironmentConfigRequest { + window_info: config.window_info, + basic_settings: config.basic_settings, + fingerprint_settings: config.fingerprint_settings, + device_settings: config.device_settings, + preference_settings: config.preference_settings, + project_metadata: config.project_metadata, + }, + }; + + // 创建环境 + let env_uuid = services::environments::create_environment_service( + svc_ctx, + user_uuid, + workspace_uuid, + team_uuid, + &create_req, + ) + .await + .map_err(|e| e.to_string())?; + + // 增加模板使用次数 + let _ = models::increment_template_usage(&svc_ctx.db, payload.template_uuid).await; + + Ok(env_uuid) +} diff --git a/server/src/services/time.rs b/server/src/services/time.rs new file mode 100644 index 00000000..2782dde4 --- /dev/null +++ b/server/src/services/time.rs @@ -0,0 +1,4 @@ +pub fn now_service() -> String { + let now = chrono::Utc::now().to_string(); + now +} diff --git a/server/src/services/users.rs b/server/src/services/users.rs new file mode 100644 index 00000000..fd33f90a --- /dev/null +++ b/server/src/services/users.rs @@ -0,0 +1,498 @@ +use uuid::Uuid; + +use crate::caches::{ + get_register_code, get_reset_password_code, get_user_public_key, set_register_code, + set_reset_password_code, set_user_public_key, +}; +use crate::entitys::{ + LoginRequest, LoginResponse, RegisterRequest, RegisterResponse, ResetPasswordRequest, + UpdatePasswordRequest, UpdateUserRequest, UserResponse, VerifyPasswordRequest, + VerifyPasswordResponse, +}; +use crate::models::{ + billing, + user::{ + create_user_with_info, fetch_user_by_uuid, fetch_user_info_by_email, + fetch_user_info_by_uuid, update_password, update_user_info, + }, +}; +use crate::services::messages; +use crate::svc_ctx::SvcCtx; +use crate::utils::{ + encryption_password, generate_token, random_six_number_code, send_email, verify_password, +}; + +/// 通用登录逻辑(内部辅助函数) +/// +/// 处理登录、注册、记住密码登录的共同逻辑: +/// - 生成 Token +/// - 保存公钥到缓存 +/// - 获取用户信息 +async fn common_login_logic( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + public_key: Option<&String>, +) -> Result<(String, String, Option), anyhow::Error> { + let secret = svc_ctx.config.app.secret.as_bytes(); + + // 1. 生成 Token + let access_token = generate_token(&user_uuid.to_string(), 60 * 60 * 2, secret)?; // 2 小时 + let refresh_token = generate_token(&user_uuid.to_string(), 60 * 60 * 24 * 7, secret)?; // 7 天 + + // 2. 如果提供了公钥,保存到缓存 + if let Some(ref public_key) = public_key { + if !public_key.is_empty() { + let _ = set_user_public_key(svc_ctx, &user_uuid, public_key).await; + // 保存失败不影响登录流程,只记录错误 + } + } + + // 3. 获取用户信息 + let user_info = get_current_user_service(svc_ctx, user_uuid).await.ok(); + + Ok((access_token, refresh_token, user_info)) +} + +/// 用户注册服务 +/// +/// # Arguments +/// * `svc_ctx` - 服务上下文 +/// * `payload` - 注册请求数据 +/// +/// # Returns +/// 返回注册响应 +pub async fn register_service( + svc_ctx: &SvcCtx, + payload: &RegisterRequest, +) -> Result { + let pool = &svc_ctx.db; + // 1. 验证验证码 + let cached_code = get_register_code(svc_ctx, &payload.email).await?; + if cached_code != Some(payload.code.clone()) { + return Err(anyhow::anyhow!("验证码错误或已过期")); + } + + // 2. 检查邮箱是否已存在 + let existing_user = fetch_user_info_by_email(pool, &payload.email).await?; + if existing_user.is_some() { + return Err(anyhow::anyhow!("邮箱已被注册")); + } + + // 3. 加密密码 + let password_hash = encryption_password(&payload.password)?; + + // 4. 生成用户 ID(使用 UUID 的前8位作为用户 ID) + let user_id = format!("USER{}", Uuid::new_v4().to_string()[..8].to_uppercase()); + + // 5. 创建用户(传入默认配额配置) + let quota = &svc_ctx.config.workspace_quota.default; + let user_uuid = create_user_with_info(pool, user_id, payload, &password_hash, quota).await?; + + // 5.1. 自动发放新用户优惠券(失败不影响注册流程) + let _ = issue_welcome_coupons(svc_ctx, user_uuid).await; + + // 6. 执行通用登录逻辑 + let (access_token, refresh_token, user_info) = + common_login_logic(svc_ctx, user_uuid, payload.public_secret_key.as_ref()).await?; + + // 7. 发送欢迎系统通知 + let _ = messages::create_message_service( + svc_ctx, + None, // 系统消息,无发送者 + &crate::entitys::CreateMessageRequest { + message_type: "system_notification".to_string(), + title: "欢迎加入".to_string(), + content: Some("感谢您的注册!我们很高兴您能加入我们。祝您使用愉快!".to_string()), + recipient_uuids: vec![user_uuid], + recipient_type: "single".to_string(), + related_type: None, + related_uuid: None, + priority: Some("normal".to_string()), + metadata: None, + }, + ) + .await; + // 注意:消息创建失败不影响注册流程,使用 _ 忽略错误 + + Ok(RegisterResponse { + access_token, + refresh_token, + user_info, + }) +} + +/// 自动发放新用户优惠券 +/// +/// 在用户注册时自动发放欢迎优惠券,失败不影响注册流程 +async fn issue_welcome_coupons(svc_ctx: &SvcCtx, user_uuid: Uuid) -> Result<(), anyhow::Error> { + // 新用户优惠券代码列表(可根据需要配置) + let welcome_coupon_codes = vec!["WELCOME10", "FIRST10"]; + + for coupon_code in welcome_coupon_codes { + // 查询优惠券 + if let Some(coupon) = billing::fetch_coupon_by_code(&svc_ctx.db, coupon_code).await? { + // 发放优惠券给用户(继承优惠券的过期时间) + let _ = billing::insert_user_coupon( + &svc_ctx.db, + user_uuid, + coupon.uuid, + coupon.valid_until, + ) + .await; + // 注意:如果用户已拥有该优惠券(重复发放),忽略错误 + } + } + + Ok(()) +} + +/// 用户登录服务(统一处理两种登录方式) +/// +/// # Arguments +/// * `svc_ctx` - 服务上下文 +/// * `payload` - 登录请求数据 +/// +/// # Returns +/// 返回登录响应 +pub async fn login_service( + svc_ctx: &SvcCtx, + payload: LoginRequest, +) -> Result { + let pool = &svc_ctx.db; + let (user_uuid, public_secret_key) = match payload { + LoginRequest::Basic(data) => { + // 基本登录:验证邮箱和密码 + // 1. 查询用户 + let user_info = fetch_user_info_by_email(pool, &data.email) + .await? + .ok_or_else(|| anyhow::anyhow!("账号不存在"))?; + + // 2. 验证密码 + if !verify_password(&data.password, &user_info.password) { + return Err(anyhow::anyhow!("密码错误")); + } + + // 3. 检查用户状态 + if user_info.status != "active" { + return Err(anyhow::anyhow!("用户已被禁用")); + } + + (user_info.user_uuid, data.public_secret_key) + } + LoginRequest::Remember(data) => { + // 记住密码登录:验证 refresh_token + // 1. 验证 refresh_token + let secret = svc_ctx.config.app.secret.as_bytes(); + let user_uuid_str = verify_token_service(&data.refresh_token, secret)?; + let user_uuid = Uuid::parse_str(&user_uuid_str)?; + + // 2. 查询用户信息 + let user_info = fetch_user_info_by_uuid(pool, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("账号不存在"))?; + + // 3. 验证邮箱是否匹配 + if user_info.email != data.email { + return Err(anyhow::anyhow!("邮箱不匹配")); + } + + // 4. 检查用户状态 + if user_info.status != "active" { + return Err(anyhow::anyhow!("用户已被禁用")); + } + + (user_uuid, data.public_secret_key) + } + }; + + // 执行通用登录逻辑 + let (access_token, refresh_token, user_response) = + common_login_logic(svc_ctx, user_uuid, public_secret_key.as_ref()).await?; + + Ok(LoginResponse { + access_token, + refresh_token, + user_info: user_response, + }) +} + +/// 验证 Token 服务 +/// +/// # Arguments +/// * `token` - 要验证的 token +/// * `secret` - JWT 密钥 +/// +/// # Returns +/// 返回用户 UUID +pub fn verify_token_service(token: &str, secret: &[u8]) -> Result { + crate::utils::verify_token(token, secret) +} + +/// 刷新 Token 服务 +/// +/// # Arguments +/// * `pool` - 数据库连接池 +/// * `svc_ctx` - 服务上下文 +/// * `refresh_token` - 刷新令牌 +/// +/// # Returns +/// 返回登录响应 +pub async fn refresh_token_service( + svc_ctx: &SvcCtx, + refresh_token: &str, +) -> Result { + let pool = &svc_ctx.db; + // 1. 验证 refresh_token + let secret = svc_ctx.config.app.secret.as_bytes(); + let user_uuid_str = verify_token_service(refresh_token, secret)?; + + // 2. 验证用户是否存在 + let user_uuid = Uuid::parse_str(&user_uuid_str)?; + let user = fetch_user_by_uuid(pool, user_uuid).await?; + if user.is_none() { + return Err(anyhow::anyhow!("用户不存在")); + } + + // 3. 生成新的 Token + let access_token = generate_token(&user_uuid_str, 60 * 60 * 2, secret)?; // 2 小时 + let new_refresh_token = generate_token(&user_uuid_str, 60 * 60 * 24 * 7, secret)?; // 7 天 + + Ok(LoginResponse { + access_token, + refresh_token: new_refresh_token, + user_info: None, + }) +} + +/// 获取当前用户信息服务 +/// +/// # Arguments +/// * `svc_ctx` - 服务上下文 +/// * `user_uuid` - 用户 UUID +/// +/// # Returns +/// 返回用户信息响应(包含当前团队信息) +pub async fn get_current_user_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + let pool = &svc_ctx.db; + let user = fetch_user_by_uuid(pool, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("用户不存在"))?; + + let user_info = fetch_user_info_by_uuid(pool, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("用户信息不存在"))?; + + // 获取当前团队详细信息 + let current_team = if let Some(team_uuid) = user_info.current_team_uuid { + // 查询团队详细信息 + crate::models::teams::fetch_team_by_uuid(pool, team_uuid).await.ok().flatten() + } else { + None + }; + + // 获取当前工作空间详细信息 + let current_workspace = if let Some(workspace_uuid) = user_info.current_workspace_uuid { + // 查询工作空间详细信息 + crate::models::workspaces::fetch_workspace_by_uuid(pool, workspace_uuid) + .await + .ok() + .flatten() + } else { + None + }; + + let avatar_url = user_info.avatar_hash.as_ref().map(|hash| { + crate::utils::storage::get_objects::get_avatar_url( + &svc_ctx.config.storage.public_base_url, + &svc_ctx.config.storage.avatar_root, + hash, + ) + }); + + Ok(UserResponse { + uuid: user.uuid, + id: user.id, + nickname: user_info.nickname, + email: user_info.email, + phone: user_info.phone, + avatar_hash: user_info.avatar_hash, + avatar_url, + status: user_info.status, + created_at: user.created_at, + updated_at: user.updated_at, + current_team, + current_workspace, + }) +} + +/// 更新用户信息服务 +/// +/// # Arguments +/// * `pool` - 数据库连接池 +/// * `user_uuid` - 用户 UUID +/// * `payload` - 更新请求数据 +pub async fn update_user_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdateUserRequest, +) -> Result<(), anyhow::Error> { + let pool = &svc_ctx.db; + update_user_info(pool, user_uuid, payload).await?; + Ok(()) +} + +/// 修改密码服务 +/// +/// # Arguments +/// * `pool` - 数据库连接池 +/// * `user_uuid` - 用户 UUID +/// * `payload` - 修改密码请求数据 +pub async fn update_password_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdatePasswordRequest, +) -> Result<(), anyhow::Error> { + let pool = &svc_ctx.db; + // 1. 查询用户 + let user_info = fetch_user_info_by_uuid(pool, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("用户不存在"))?; + + // 2. 验证旧密码 + if !verify_password(&payload.old_password, &user_info.password) { + return Err(anyhow::anyhow!("原密码错误")); + } + + // 3. 加密新密码 + let password_hash = encryption_password(&payload.new_password)?; + + // 4. 更新密码 + update_password(pool, user_uuid, &password_hash).await?; + + Ok(()) +} + +/// 校验当前用户密码 +pub async fn verify_password_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &VerifyPasswordRequest, +) -> Result { + let user_info = fetch_user_info_by_uuid(&svc_ctx.db, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("用户不存在"))?; + + if user_info.status != "active" { + return Err(anyhow::anyhow!("用户已被禁用")); + } + + Ok(VerifyPasswordResponse { + valid: verify_password(&payload.password, &user_info.password), + }) +} + +/// 重置密码服务 +/// +/// # Arguments +/// * `pool` - 数据库连接池 +/// * `svc_ctx` - 服务上下文 +/// * `payload` - 重置密码请求数据 +pub async fn reset_password_service( + svc_ctx: &SvcCtx, + payload: &ResetPasswordRequest, +) -> Result<(), anyhow::Error> { + let pool = &svc_ctx.db; + // 1. 验证验证码 + let cached_code = get_reset_password_code(svc_ctx, &payload.email).await?; + if cached_code != Some(payload.code.clone()) { + return Err(anyhow::anyhow!("验证码错误或已过期")); + } + + // 2. 查询用户 + let user_info = fetch_user_info_by_email(pool, &payload.email) + .await? + .ok_or_else(|| anyhow::anyhow!("用户不存在"))?; + + // 3. 加密新密码 + let password_hash = encryption_password(&payload.new_password)?; + + // 4. 更新密码 + update_password(pool, user_info.user_uuid, &password_hash).await?; + + Ok(()) +} + +/// 发送验证码服务 +/// +/// # Arguments +/// * `svc_ctx` - 服务上下文 +/// * `email` - 邮箱地址 +/// * `code_type` - 验证码类型(register 或 reset_password) +pub async fn send_verification_code_service( + svc_ctx: &SvcCtx, + email: &str, + code_type: &str, +) -> Result<(), anyhow::Error> { + if code_type == "reset_password" { + let user_exists = fetch_user_info_by_email(&svc_ctx.db, email).await?.is_some(); + if !user_exists { + return Err(anyhow::anyhow!("用户不存在")); + } + } + + let smtp_config = svc_ctx + .config + .clone() + .smtp + .ok_or_else(|| anyhow::anyhow!("邮箱配置不存在"))?; + + // 1. 生成验证码 + let code = random_six_number_code(); + + // 2. 存储验证码到 Redis + match code_type { + "register" => set_register_code(svc_ctx, email, &code).await?, + "reset_password" => set_reset_password_code(svc_ctx, email, &code).await?, + _ => return Err(anyhow::anyhow!("无效的验证码类型")), + } + + // 3. 发送邮件 + let title = match code_type { + "register" => "注册验证码", + "reset_password" => "重置密码验证码", + _ => "验证码", + }; + let body = format!("您的验证码是:{},有效期 5 分钟。", code); + + send_email( + &smtp_config.smtp_username, + &smtp_config.smtp_password, + &smtp_config.smtp_server, + email, + title, + &body, + )?; + + Ok(()) +} + +/// 获取用户公钥 +/// +/// # Arguments +/// * `svc_ctx` - 服务上下文 +/// * `user_uuid` - 用户对应的UUID +/// +/// # Returns +/// 返回用户公钥,如果获取成功则返回公钥,否则返回错误 +pub async fn get_user_public_key_service( + svc_ctx: &SvcCtx, + user_uuid: &Uuid, +) -> Result { + let public_key = get_user_public_key(svc_ctx, user_uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("用户公钥不存在"))?; + Ok(public_key) +} diff --git a/server/src/services/version_types.rs b/server/src/services/version_types.rs new file mode 100644 index 00000000..324ecc7c --- /dev/null +++ b/server/src/services/version_types.rs @@ -0,0 +1,123 @@ +use crate::{ + dto::version_types::VersionType, entitys::version_types::*, errors::SimprintError, + svc_ctx::SvcCtx, +}; + +/// 创建版本类型 +pub async fn create_version_type( + svc_ctx: &SvcCtx, + request: CreateVersionTypeRequest, +) -> Result { + // 验证类型代码 + if request.type_code.is_empty() { + return Err(SimprintError::Other("版本类型代码不能为空".to_string())); + } + + // 检查版本类型是否已存在 + let exists = + crate::models::version_types::query_version_type_by_code(&svc_ctx.db, &request.type_code) + .await + .ok(); + + if exists.is_some() { + return Err(SimprintError::Other("版本类型已存在".to_string())); + } + + // 创建版本类型 + let id = crate::models::version_types::insert_version_type(&svc_ctx.db, &request).await?; + + Ok(id) +} + +/// 根据ID查询版本类型 +pub async fn get_version_type_by_id( + svc_ctx: &SvcCtx, + id: i32, +) -> Result { + let version_type = crate::models::version_types::query_version_type_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::Other("版本类型不存在".to_string()))?; + + Ok(version_type) +} + +/// 根据代码查询版本类型 +pub async fn get_version_type_by_code( + svc_ctx: &SvcCtx, + type_code: String, +) -> Result { + let version_type = + crate::models::version_types::query_version_type_by_code(&svc_ctx.db, &type_code) + .await + .map_err(|_| SimprintError::Other("版本类型不存在".to_string()))?; + + Ok(version_type) +} + +/// 查询所有版本类型 +pub async fn query_all_version_types_service( + svc_ctx: &SvcCtx, +) -> Result { + let list = crate::models::version_types::query_all_version_types(&svc_ctx.db).await?; + + Ok(VersionTypeListResponse { list }) +} + +/// 查询激活的版本类型 +pub async fn query_active_version_types_service( + svc_ctx: &SvcCtx, +) -> Result { + let list = crate::models::version_types::query_active_version_types(&svc_ctx.db).await?; + + Ok(VersionTypeListResponse { list }) +} + +/// 更新版本类型 +pub async fn update_version_type_service( + svc_ctx: &SvcCtx, + id: i32, + request: UpdateVersionTypeRequest, +) -> Result { + // 检查版本类型是否存在 + crate::models::version_types::query_version_type_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::Other("版本类型不存在".to_string()))?; + + // 更新版本类型 + let success = + crate::models::version_types::update_version_type(&svc_ctx.db, id, &request).await?; + + if !success { + return Err(SimprintError::Other("更新版本类型失败".to_string())); + } + + Ok(true) +} + +/// 删除版本类型 +pub async fn delete_version_type_service(svc_ctx: &SvcCtx, id: i32) -> Result { + let success = crate::models::version_types::delete_version_type(&svc_ctx.db, id).await?; + + if !success { + return Err(SimprintError::Other("删除版本类型失败".to_string())); + } + + Ok(true) +} + +/// 激活/停用版本类型 +pub async fn toggle_version_type_status_service( + svc_ctx: &SvcCtx, + id: i32, + is_active: bool, +) -> Result { + let success = + crate::models::version_types::toggle_version_type_status(&svc_ctx.db, id, is_active) + .await?; + + if !success { + return Err(SimprintError::Other("激活/停用版本类型失败".to_string())); + } + + Ok(true) +} diff --git a/server/src/services/versions.rs b/server/src/services/versions.rs new file mode 100644 index 00000000..8ad02c09 --- /dev/null +++ b/server/src/services/versions.rs @@ -0,0 +1,240 @@ +use crate::{ + dto::versions::Version, entitys::versions::*, errors::SimprintError, svc_ctx::SvcCtx, + utils::get_objects::get_version_resource_url, +}; +use std::collections::HashMap; + +/// 创建版本 +pub async fn create_version( + svc_ctx: &SvcCtx, + request: CreateVersionRequest, +) -> Result { + // 验证版本号 + if request.version.is_empty() { + return Err(SimprintError::VersionEmpty); + } + + // 验证资源名称 + if request.resource_name.is_empty() { + return Err(SimprintError::ResourceNameEmpty); + } + + // 检查版本是否已存在 + let exists = crate::models::versions::query_version_by_name_and_version( + &svc_ctx.db, + &request.resource_name, + &request.version, + ) + .await + .ok(); + + if exists.is_some() { + return Err(SimprintError::VersionAlreadyExists); + } + + // 创建版本 + let id = crate::models::versions::insert_version(&svc_ctx.db, &request).await?; + + Ok(id) +} + +/// 根据ID查询版本 +pub async fn get_version_by_id(svc_ctx: &SvcCtx, id: i32) -> Result { + let mut version = crate::models::versions::query_version_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + let storage_config = &svc_ctx.config.storage; + let object_path = version.url.clone().unwrap_or_default(); + version.url = Some(get_version_resource_url( + &storage_config.public_base_url, + &storage_config.version_root, + &object_path, + )); + + Ok(version) +} + +/// 根据资源名称和版本号查询版本 +pub async fn get_version_by_name_and_version( + svc_ctx: &SvcCtx, + resource_name: String, + version: String, +) -> Result { + let version_data = crate::models::versions::query_version_by_name_and_version( + &svc_ctx.db, + &resource_name, + &version, + ) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + Ok(version_data) +} + +/// 查询最新版本 +pub async fn get_latest_version( + svc_ctx: &SvcCtx, + resource_name: String, + platform: String, +) -> Result { + let version = + crate::models::versions::query_latest_version(&svc_ctx.db, &resource_name, &platform) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + version.ok_or(SimprintError::VersionNotFound) +} + +/// 查询版本列表 +pub async fn query_versions_service( + svc_ctx: &SvcCtx, + params: QueryVersionParams, + page_num: i32, + page_size: i32, +) -> Result { + let (total, list) = crate::models::versions::query_versions( + &svc_ctx.db, + params.resource_name.as_deref(), + params.platform.as_deref(), + params.status.as_deref(), + page_num, + page_size, + ) + .await?; + + Ok(VersionListResponse { total, list }) +} + +/// 更新版本 +pub async fn update_version_service( + svc_ctx: &SvcCtx, + id: i32, + request: UpdateVersionRequest, +) -> Result { + // 检查版本是否存在 + crate::models::versions::query_version_by_id(&svc_ctx.db, id) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + // 更新版本 + let success = crate::models::versions::update_version(&svc_ctx.db, id, &request).await?; + + if !success { + return Err(SimprintError::VersionNotFound); + } + + Ok(true) +} + +/// 删除版本 +pub async fn delete_version_service(svc_ctx: &SvcCtx, id: i32) -> Result { + let success = crate::models::versions::delete_version(&svc_ctx.db, id).await?; + + if !success { + return Err(SimprintError::VersionNotFound); + } + + Ok(true) +} + +/// 设置某版本为最新版本 +pub async fn set_latest_version_service( + svc_ctx: &SvcCtx, + type_id: i32, + resource_name: String, + version_id: i32, +) -> Result { + // 检查版本是否存在 + crate::models::versions::query_version_by_id(&svc_ctx.db, version_id) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + // 设置为最新版本 + let success = crate::models::versions::set_as_latest_version( + &svc_ctx.db, + type_id, + &resource_name, + version_id, + ) + .await?; + + if !success { + return Err(SimprintError::Other("设置最新版本失败".to_string())); + } + + Ok(true) +} + +/// 版本回退到指定版本 +pub async fn rollback_version_service( + svc_ctx: &SvcCtx, + type_id: i32, + resource_name: String, + target_version_id: i32, +) -> Result { + // 检查目标版本是否存在 + let target_version = + crate::models::versions::query_version_by_id(&svc_ctx.db, target_version_id) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + // 检查版本是否属于同一资源 + if target_version.type_id != type_id || target_version.resource_name != resource_name { + return Err(SimprintError::VersionNotFound); + } + + // 设置目标版本为最新 + let success = + set_latest_version_service(svc_ctx, type_id, resource_name, target_version_id).await?; + + if !success { + return Err(SimprintError::Other("版本回退失败".to_string())); + } + + Ok(true) +} + +/// 版本差异对比 +pub async fn compare_versions_service( + svc_ctx: &SvcCtx, + version_id_1: i32, + version_id_2: i32, +) -> Result<(Version, Version), SimprintError> { + let version_1 = crate::models::versions::query_version_by_id(&svc_ctx.db, version_id_1) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + let version_2 = crate::models::versions::query_version_by_id(&svc_ctx.db, version_id_2) + .await + .map_err(|_| SimprintError::VersionNotFound)?; + + Ok((version_1, version_2)) +} + +/// 查询所有激活版本类型的最新版本 +pub async fn get_all_active_latest_versions_service( + svc_ctx: &SvcCtx, + platform: String, +) -> Result>, SimprintError> { + let results = + crate::models::versions::query_all_active_latest_versions(&svc_ctx.db, &platform).await?; + + // 转换为 HashMap> + let mut map: HashMap> = HashMap::new(); + + let storage_config = &svc_ctx.config.storage; + + for (type_code, _resource_name, version) in results { + map.entry(type_code).or_insert_with(Vec::new).push(Version { + url: Some(get_version_resource_url( + &storage_config.public_base_url, + &storage_config.version_root, + &version.url.unwrap_or_default(), + )), + ..version + }); + } + + Ok(map) +} diff --git a/server/src/services/wallet.rs b/server/src/services/wallet.rs new file mode 100644 index 00000000..b7c4fa74 --- /dev/null +++ b/server/src/services/wallet.rs @@ -0,0 +1,103 @@ +use rust_decimal::Decimal; +use uuid::Uuid; + +use crate::dto::{UserWalletDto, WalletTransactionDto}; +use crate::entitys::ListTransactionsRequest; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 获取钱包信息 +pub async fn get_wallet_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + let wallet = models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果钱包不存在,创建一个 + if wallet.is_none() { + models::billing::insert_user_wallet(&svc_ctx.db, user_uuid, "CNY") + .await + .map_err(|e| e.to_string())?; + + return models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "创建钱包失败".to_string()); + } + + wallet.ok_or_else(|| "钱包不存在".to_string()) +} + +/// 扣减钱包余额 +pub async fn deduct_wallet_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + amount: Decimal, + description: &str, + order_uuid: Option, +) -> Result<(), String> { + let wallet = models::billing::fetch_user_wallet(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "钱包不存在".to_string())?; + + let balance_before = wallet.balance; + let balance_after = balance_before - amount; + + if balance_after < Decimal::ZERO { + return Err("余额不足".to_string()); + } + + // 扣减余额 + models::billing::update_wallet_balance(&svc_ctx.db, user_uuid, -amount) + .await + .map_err(|e| e.to_string())?; + + // 记录交易 + models::billing::insert_wallet_transaction( + &svc_ctx.db, + user_uuid, + "debit", + -amount, + &wallet.currency, + balance_before, + balance_after, + Some(description), + order_uuid, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} + +/// 获取交易记录 +pub async fn get_transactions_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListTransactionsRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let transactions = models::billing::fetch_wallet_transactions( + &svc_ctx.db, + user_uuid, + payload.transaction_type.as_deref(), + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::billing::fetch_wallet_transactions_count( + &svc_ctx.db, + user_uuid, + payload.transaction_type.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + Ok((transactions, total)) +} diff --git a/server/src/services/workspace_quotas.rs b/server/src/services/workspace_quotas.rs new file mode 100644 index 00000000..7fb0b853 --- /dev/null +++ b/server/src/services/workspace_quotas.rs @@ -0,0 +1,72 @@ +use uuid::Uuid; + +use crate::dto::WorkspaceQuotaDto; +use crate::entitys::{GetWorkspaceQuotaRequest, UpdateQuotaUsageRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 获取工作空间配额 +pub async fn get_workspace_quota_service( + svc_ctx: &SvcCtx, + payload: &GetWorkspaceQuotaRequest, +) -> Result { + let workspace_uuid = payload + .workspace_uuid + .ok_or_else(|| "工作空间 UUID 不能为空".to_string())?; + + models::fetch_workspace_quota(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "配额不存在".to_string()) +} + +/// 检查配额是否充足 +pub async fn check_quota_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + quota_type: &str, +) -> Result { + models::check_quota(&svc_ctx.db, workspace_uuid, quota_type) + .await + .map_err(|e| e.to_string()) +} + +/// 更新配额使用情况 +pub async fn update_quota_usage_service( + svc_ctx: &SvcCtx, + payload: &UpdateQuotaUsageRequest, +) -> Result<(), String> { + match payload.quota_type.as_str() { + "environments" => { + if payload.increment { + models::increment_used_environments( + &svc_ctx.db, + payload.workspace_uuid, + payload.amount, + ) + .await + } else { + models::decrement_used_environments( + &svc_ctx.db, + payload.workspace_uuid, + payload.amount, + ) + .await + } + } + "proxies" => { + if payload.increment { + models::increment_used_proxies(&svc_ctx.db, payload.workspace_uuid, payload.amount) + .await + } else { + models::decrement_used_proxies(&svc_ctx.db, payload.workspace_uuid, payload.amount) + .await + } + } + "team_members" => { + models::update_used_team_members(&svc_ctx.db, payload.workspace_uuid).await + } + _ => return Err("不支持的配额类型".to_string()), + } + .map_err(|e| e.to_string()) +} diff --git a/server/src/services/workspaces.rs b/server/src/services/workspaces.rs new file mode 100644 index 00000000..1abc0135 --- /dev/null +++ b/server/src/services/workspaces.rs @@ -0,0 +1,184 @@ +use uuid::Uuid; + +use crate::dto::WorkspaceDto; +use crate::entitys::{CreateTeamRequest, CreateWorkspaceRequest, UpdateWorkspaceRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建工作空间 +pub async fn create_workspace_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &CreateWorkspaceRequest, +) -> Result { + // 获取用户信息,用于生成团队名称 + let user_info = models::user::fetch_user_info_by_uuid(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "用户不存在".to_string())?; + + // 生成团队名称 + let team_name = user_info + .nickname + .as_ref() + .map(|n| format!("{} 的团队", n)) + .unwrap_or_else(|| { + format!( + "{} 的团队", + user_info + .email + .split('@') + .next() + .unwrap_or("用户") + ) + }); + + // 创建工作空间 + let workspace_uuid = models::insert_workspace(&svc_ctx.db, user_uuid, payload) + .await + .map_err(|e| e.to_string())?; + + // 创建默认配额(从配置读取) + let quota = &svc_ctx.config.workspace_quota.default; + models::insert_or_update_workspace_quota( + &svc_ctx.db, + workspace_uuid, + quota.max_environments, + quota.max_team_members, + quota.max_proxies, + quota.max_rpa_tasks, + ) + .await + .map_err(|e| e.to_string())?; + + // 创建默认团队(每个工作空间自动创建一个团队) + let team_request = CreateTeamRequest { + workspace_uuid, + name: team_name, + description: Some("默认团队".to_string()), + }; + let team_uuid = models::insert_team(&svc_ctx.db, user_uuid, &team_request) + .await + .map_err(|e| e.to_string())?; + + // 设置用户当前工作空间和团队,确保上下文始终一致。 + models::user::set_user_current_workspace_and_team(&svc_ctx.db, user_uuid, workspace_uuid, team_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok(workspace_uuid) +} + +/// 获取工作空间详情 +pub async fn get_workspace_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, +) -> Result { + models::fetch_workspace_by_uuid(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间不存在".to_string()) +} + +/// 获取用户所属的所有工作空间 +pub async fn get_user_workspaces_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::fetch_user_workspaces(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 更新工作空间 +pub async fn update_workspace_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdateWorkspaceRequest, +) -> Result<(), String> { + // 检查权限(只有所有者可以更新) + let workspace = models::fetch_workspace_by_uuid(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间不存在".to_string())?; + + if workspace.owner_uuid != user_uuid { + return Err("只有工作空间所有者可以更新".to_string()); + } + + models::update_workspace(&svc_ctx.db, payload.uuid, payload.name.as_deref()) + .await + .map_err(|e| e.to_string()) +} + +/// 删除工作空间(软删除) +pub async fn delete_workspace_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result<(), String> { + // 检查权限(只有所有者可以删除) + let workspace = models::fetch_workspace_by_uuid(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间不存在".to_string())?; + + if workspace.owner_uuid != user_uuid { + return Err("只有工作空间所有者可以删除".to_string()); + } + + let current_workspace_uuid = models::user::fetch_user_current_workspace(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if current_workspace_uuid == Some(workspace_uuid) { + return Err("不能删除当前正在使用的工作空间,请先切换到其他工作空间".to_string()); + } + + // 检查用户是否只有一个工作空间,如果是则不允许删除 + let user_workspaces = models::fetch_user_workspaces(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if user_workspaces.len() <= 1 { + return Err("至少需要保留一个工作空间,无法删除最后一个工作空间".to_string()); + } + + models::delete_workspace(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 检查用户是否是工作空间所有者 +pub async fn check_workspace_owner_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result { + models::check_workspace_owner(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +pub async fn switch_workspace_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), String> { + let teams = models::fetch_user_teams(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let current_team_uuid = models::fetch_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let team_uuid = current_team_uuid + .filter(|current| teams.iter().any(|team| team.uuid == *current)) + .or_else(|| teams.first().map(|team| team.uuid)) + .ok_or_else(|| "该工作空间下没有可用团队".to_string())?; + + models::user::set_user_current_workspace_and_team(&svc_ctx.db, user_uuid, workspace_uuid, team_uuid) + .await + .map_err(|e| e.to_string()) +} diff --git a/server/src/state.rs b/server/src/state.rs new file mode 100644 index 00000000..eccd6ffc --- /dev/null +++ b/server/src/state.rs @@ -0,0 +1,82 @@ +use uuid::Uuid; + +/// 当前用户 +#[derive(Debug, Clone)] +pub struct CurrentUser { + pub user_uuid: Uuid, +} + +/// 当前工作空间 +#[derive(Debug, Clone)] +pub struct CurrentWorkspace { + pub workspace_uuid: Uuid, +} + +/// 当前 IP 地址 +#[derive(Debug, Clone)] +pub struct CurrentIpAddr { + pub real_ip: String, +} + +/// 请求上下文 - 包含所有请求相关的上下文信息 +/// +/// 在中间件中逐步填充,handler 中通过 Extension 获取 +#[derive(Debug, Clone, Default)] +pub struct RequestContext { + /// 当前用户信息 + pub current_user: Option, + /// 当前 IP 地址 + pub current_ip_addr: Option, + /// 当前团队 UUID + pub current_team_uuid: Option, + /// 当前工作空间 UUID + pub current_workspace_uuid: Option, + /// 资源标识符:method+path(去除 /api/v*/ 前缀,保留前导斜杠) + /// 例如:POST+/environments, GET+/proxies + pub resource_identifier: Option, +} + +impl RequestContext { + /// 获取用户 UUID(如果已认证) + pub fn user_uuid(&self) -> Option { + self.current_user.as_ref().map(|u| u.user_uuid) + } + + /// 获取用户 UUID,如果未认证则 panic + pub fn user_uuid_unwrap(&self) -> Uuid { + self.current_user.as_ref().expect("用户未认证").user_uuid + } + + /// 获取 IP 地址 + pub fn ip(&self) -> Option<&str> { + self.current_ip_addr.as_ref().map(|i| i.real_ip.as_str()) + } + + /// 获取 IP 地址,如果不存在则返回 "unknown" + pub fn ip_or_unknown(&self) -> &str { + self.ip().unwrap_or("unknown") + } + + /// 获取工作空间 UUID + pub fn workspace_uuid(&self) -> Option { + self.current_workspace_uuid + } + + /// 获取工作空间 UUID,如果不存在则 panic + pub fn workspace_uuid_unwrap(&self) -> Uuid { + self.current_workspace_uuid.expect("工作空间未设置") + } + + /// 获取资源标识符 + pub fn resource_identifier(&self) -> Option<&str> { + self.resource_identifier.as_deref() + } + + /// 获取资源路径(从资源标识符中提取路径部分) + /// 例如:POST+/environments -> /environments + pub fn resource_path(&self) -> Option<&str> { + self.resource_identifier + .as_ref() + .and_then(|id| id.split_once('+').map(|(_, path)| path)) + } +} diff --git a/server/src/svc_ctx.rs b/server/src/svc_ctx.rs new file mode 100644 index 00000000..bd84a759 --- /dev/null +++ b/server/src/svc_ctx.rs @@ -0,0 +1,30 @@ +use crate::{ + caches::CacheStore, + database::{self, DbPool}, + utils::{DatabaseConfig, IConfig}, +}; + +/// Shared resources used by handlers and services. +#[derive(Clone)] +pub struct SvcCtx { + pub config: IConfig, + pub db: DbPool, + pub cache: CacheStore, +} + +impl SvcCtx { + pub async fn new(config: &IConfig) -> Result { + let db = Self::create_db(&config.database).await?; + let cache = CacheStore::memory(); + + Ok(Self { + config: config.clone(), + db, + cache, + }) + } + + pub async fn create_db(config: &DatabaseConfig) -> Result { + database::connect(config).await + } +} diff --git a/server/src/utils.rs b/server/src/utils.rs new file mode 100644 index 00000000..1cd88996 --- /dev/null +++ b/server/src/utils.rs @@ -0,0 +1,50 @@ +mod config; +mod extractor; +mod jwt; +mod password; +mod responses; +mod secret; +pub mod storage; + +pub use config::*; +pub use extractor::*; +pub use jwt::*; +pub use password::*; +pub use responses::*; +pub use secret::*; +pub use storage::*; + +/// 向指定邮箱发送验证码 +pub fn send_email( + smtp_username: &str, + smtp_password: &str, + smtp_server: &str, + to: &str, + title: &str, + body: &str, +) -> std::result::Result { + use lettre::message::header::ContentType; + use lettre::transport::smtp::authentication::Credentials; + use lettre::{Message, SmtpTransport, Transport}; + + let email = Message::builder() + .from(smtp_username.parse().map_err(|_| anyhow::anyhow!("from email is failed."))?) + .to(to.parse().map_err(|_| anyhow::anyhow!("receive email is failed."))?) + .subject(title) + .header(ContentType::TEXT_HTML) + .body(String::from(body))?; + let creds = Credentials::new(smtp_username.to_string(), smtp_password.to_string()); + let mailer = SmtpTransport::relay(smtp_server).unwrap().credentials(creds).build(); + + match mailer.send(&email) { + Ok(_) => Ok(true), + Err(_e) => Err(anyhow::anyhow!("email send failed.")), + } +} + +/// 随机生成6位的数字编码 +pub fn random_six_number_code() -> String { + let mut rng = rand::rng(); + let random_number: i32 = rand::Rng::random_range(&mut rng, 100000..1000000); + format!("{}", random_number) +} diff --git a/server/src/utils/config.rs b/server/src/utils/config.rs new file mode 100644 index 00000000..a964ca4c --- /dev/null +++ b/server/src/utils/config.rs @@ -0,0 +1,114 @@ +use config::{Config, ConfigError}; +use serde::Deserialize; + +/// 数据库配置 +#[derive(Debug, Clone, Deserialize)] +pub struct DatabaseConfig { + pub url: String, + pub max_connections: u32, + pub min_connections: u32, + pub max_lifetime: u64, + pub acquire_timeout: u64, + pub idle_timeout: u64, +} + +/// Download-resource URL configuration retained by the transitional HTTP API. +#[derive(Debug, Clone, Deserialize)] +pub struct StorageConfig { + #[serde(default)] + pub public_base_url: String, + #[serde(default = "default_avatar_root")] + pub avatar_root: String, + #[serde(default = "default_extension_root")] + pub extension_root: String, + #[serde(default = "default_version_root")] + pub version_root: String, +} + +fn default_avatar_root() -> String { + "avatars".to_string() +} + +fn default_extension_root() -> String { + "extensions".to_string() +} + +fn default_version_root() -> String { + "versions".to_string() +} + +/// SMTP 配置 +#[derive(Debug, Clone, Deserialize)] +pub struct SmtpConfig { + pub smtp_server: String, + pub smtp_username: String, + pub smtp_password: String, +} + +/// 工作空间默认配额配置 +#[derive(Debug, Clone, Deserialize)] +pub struct WorkspaceQuotaConfig { + /// 默认配额(新用户注册和手动创建工作空间都使用此配置) + #[serde(default = "default_workspace_quota")] + pub default: WorkspaceQuotaValues, +} + +/// 工作空间配额值 +#[derive(Debug, Clone, Deserialize)] +pub struct WorkspaceQuotaValues { + pub max_environments: i32, + pub max_team_members: i32, + pub max_proxies: i32, + pub max_rpa_tasks: i32, +} + +fn default_workspace_quota() -> WorkspaceQuotaValues { + WorkspaceQuotaValues { + max_environments: 8, + max_team_members: 1, + max_proxies: 99999, + max_rpa_tasks: 99999, + } +} + +/// 应用配置 +#[derive(Debug, Clone, Deserialize)] +pub struct AppConfig { + pub name: String, + pub port: u16, + pub secret: String, + pub prefix: String, + pub encrypt_secret_location: String, + pub route_whitelists: Vec, + /// 推广链接前缀,例如: https://www.example.com/register + /// 实际推广链接将拼接为: {referral_link_prefix}?referral_code={code} + pub referral_link_prefix: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct IConfig { + pub app: AppConfig, + pub database: DatabaseConfig, + pub storage: StorageConfig, + pub smtp: Option, + #[serde(default = "default_workspace_quota_config")] + pub workspace_quota: WorkspaceQuotaConfig, +} + +fn default_workspace_quota_config() -> WorkspaceQuotaConfig { + WorkspaceQuotaConfig { + default: default_workspace_quota(), + } +} + +impl IConfig { + pub fn build_by_filepath(config_path: &str) -> Result { + let config = Config::builder() + .add_source(config::File::with_name(config_path)) + .add_source(config::File::with_name(".").required(false)) + .add_source(config::Environment::with_prefix("APP")) + .build()?; + + config.try_deserialize() + } +} diff --git a/server/src/utils/extractor.rs b/server/src/utils/extractor.rs new file mode 100644 index 00000000..78f8b822 --- /dev/null +++ b/server/src/utils/extractor.rs @@ -0,0 +1,43 @@ +use axum::{ + extract::{self, FromRequest, Request, rejection::JsonRejection}, + http::StatusCode, +}; +use serde::{Serialize, de::DeserializeOwned}; + +use crate::utils::Response; + +/// 自定义Extractor +/// +/// 不使用默认的JSON extractor, 通过该extract提取可以在提取成功或失败时完成额外的操作。 +pub struct Json(pub T); + +impl FromRequest for Json +where + T: DeserializeOwned + Serialize, + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request(req: Request, state: &S) -> Result { + match extract::Json::::from_request(req, state).await { + Ok(body) => Ok(Self(body.0)), + Err(rejection) => { + let (status, message) = match rejection { + JsonRejection::JsonDataError(e) => { + eprintln!("{:?}", e); + (StatusCode::BAD_REQUEST, "请求参数错误") + } + JsonRejection::JsonSyntaxError(_) => { + (StatusCode::BAD_REQUEST, "请求参数语法错误") + } + JsonRejection::MissingJsonContentType(_) => { + (StatusCode::BAD_REQUEST, "缺少请求参数") + } + _ => (StatusCode::OK, ""), + }; + + Err(Response::fail_with_statu_code(Some(message), status)) + } + } + } +} diff --git a/server/src/utils/jwt.rs b/server/src/utils/jwt.rs new file mode 100644 index 00000000..9bea4b89 --- /dev/null +++ b/server/src/utils/jwt.rs @@ -0,0 +1,59 @@ +use jwt::Token; + +// 生成token +pub fn generate_token( + user_uuid: &str, + expiration: u64, + secret: &[u8], +) -> Result { + use aes_gcm::KeyInit; + use hmac::Hmac; + use jwt::SignWithKey; + use std::collections::BTreeMap; + + let key: Hmac = Hmac::new_from_slice(secret).unwrap(); + + let mut claims = BTreeMap::new(); + claims.insert("uuid", format!("{}", user_uuid)); + claims.insert( + "exp", + format!("{}", chrono::Utc::now().timestamp() + expiration as i64), + ); + claims.insert("iat", format!("{}", chrono::Utc::now().timestamp())); + + let header = jwt::Header { + algorithm: jwt::AlgorithmType::Hs256, + ..Default::default() + }; + + Token::new(header, claims).sign_with_key(&key).map(|v| v.as_str().to_string()) +} + +// 校验token +pub fn verify_token(token_str: &str, secret: &[u8]) -> Result { + use aes_gcm::KeyInit; + use hmac::Hmac; + use jwt::VerifyWithKey; + use sha2::Sha256; + use std::collections::BTreeMap; + + let key: Hmac = Hmac::new_from_slice(secret)?; + let claims: BTreeMap = token_str.verify_with_key(&key)?; + let exp_str = claims.get("exp"); + + // 检查过期时间 + let exp = exp_str + .ok_or_else(|| anyhow::anyhow!("token expired."))? + .parse::() + .map_err(|_| anyhow::anyhow!("token expired."))?; + let current_time = chrono::Utc::now().timestamp() as u64; + if current_time > exp { + return Err(anyhow::anyhow!("token expired.")); + } + + let uuid = claims.get("uuid"); + match uuid { + Some(uuid) => return Ok(uuid.to_string()), + None => Err(anyhow::anyhow!("token parse failed.")), + } +} diff --git a/server/src/utils/password.rs b/server/src/utils/password.rs new file mode 100644 index 00000000..89fd423b --- /dev/null +++ b/server/src/utils/password.rs @@ -0,0 +1,26 @@ +use argon2::{ + Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, +}; + +/// 对密码进行加密 +pub fn encryption_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + + let argon2 = Argon2::default(); + + match argon2.hash_password(password.as_bytes(), &salt) { + Ok(password_hash) => Ok(password_hash.to_string()), + Err(_) => Err(anyhow::anyhow!("密码异常")), + } +} + +pub fn verify_password(password: &str, password_hash: &str) -> bool { + let parsed_hash = if let Ok(parsed_hash) = PasswordHash::new(&password_hash) { + parsed_hash + } else { + return false; + }; + + Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok() +} diff --git a/server/src/utils/responses.rs b/server/src/utils/responses.rs new file mode 100644 index 00000000..065b83d9 --- /dev/null +++ b/server/src/utils/responses.rs @@ -0,0 +1,126 @@ +use axum::{ + extract::multipart::MultipartError, + http::StatusCode, + response::{IntoResponse, Json}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::fmt::{Debug, Display}; + +const SUCCESS_CODE: i32 = 1; +const FAIL_CODE: i32 = 0; + +pub type Result = axum::response::Result, Response>; + +/// 统一响应类型 +#[derive(Debug, Serialize, Clone, Deserialize)] +pub struct Response { + pub status_code: u16, + pub code: i32, + pub data: Option, + pub message: Option, +} + +impl Response { + /// 操作成功对应的响应类型 + pub fn success(message: Option<&str>, data: Option) -> Self { + Response { + code: SUCCESS_CODE, + message: message.map(|v| v.to_string()), + data, + status_code: 200, + } + } + + /// 操作失败对应的响应类型 + pub fn fail(message: Option<&str>) -> Self { + Response { + code: FAIL_CODE, + message: message.map(|v| v.to_string()), + data: None, + status_code: 200, + } + } + + /// 操作失败对应的响应类型 + pub fn fail_with_statu_code(message: Option<&str>, statu_code: StatusCode) -> Self { + Response { + code: FAIL_CODE, + message: message.map(|v| v.to_string()), + data: None, + status_code: statu_code.as_u16(), + } + } + + /// with status code + pub fn with_status_code(self, status_code: StatusCode) -> Self { + let mut response = self; + response.status_code = status_code.as_u16(); + response + } +} + +// 允许直接打印和to_string +impl Display for Response { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Response {{ status_code: {}, code: {}, message: {:?}, has_data: {} }}", + self.status_code, + self.code, + self.message, + self.data.is_some() + ) + } +} + +// 当实现该类型后可以直接将Response作为axum路由处理函数的返回值,会自动调用该trait的into_response方法最终返回json. +impl IntoResponse for Response +where + T: Serialize, +{ + fn into_response(self) -> axum::response::Response { + let mut content_json = json!({ "code": self.code }); + + // 如果 message 存在,添加到 content_json + if let Some(message) = &self.message { + content_json["message"] = json!(message); + } + + // 如果 data 存在,添加到 content_json + if let Some(data) = &self.data { + content_json["data"] = json!(data); + } + + match StatusCode::from_u16(self.status_code) { + Ok(status) => (status, Json(content_json)), + Err(_) => (StatusCode::BAD_REQUEST, Json(content_json)), + } + .into_response() + } +} + +/// 实现From +impl From for Response<()> { + fn from(value: MultipartError) -> Self { + Response::fail_with_statu_code( + Some(format!("请求参数错误: {:?}", value.body_text()).as_ref()), + value.status(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::Response; + + #[test] + fn display_response_without_recursion() { + let response = Response::success(Some("ok"), Some(42)); + + assert_eq!( + response.to_string(), + "Response { status_code: 200, code: 1, message: Some(\"ok\"), has_data: true }" + ); + } +} diff --git a/src-tauri/src/infrastructure/http/encryption/aes.rs b/server/src/utils/secret/aes.rs similarity index 77% rename from src-tauri/src/infrastructure/http/encryption/aes.rs rename to server/src/utils/secret/aes.rs index 21adfa4d..23174a91 100644 --- a/src-tauri/src/infrastructure/http/encryption/aes.rs +++ b/server/src/utils/secret/aes.rs @@ -1,203 +1,216 @@ -use aes_gcm::{ - Aes256Gcm, Key, Nonce, - aead::{Aead, AeadCore, KeyInit, OsRng}, -}; -use anyhow::Context; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use std::fmt::Debug; - -const AES_KEY_SIZE: usize = 32; -const NONCE_SIZE: usize = 12; - -/// key本身是字节数组, 先将字节数组转换成base64编码得字符串,然后再通过非对称公钥加密再转换成base64编码的字符串 -#[derive(Debug, Clone, Default)] -pub struct AesSecret { - key: Key, // 直接存储 GenericArray 以避免重复转换 -} - -impl AesSecret { - /// 生成一个新的 AES 密钥实例 - pub fn new() -> Self { - let key_bytes = Aes256Gcm::generate_key(&mut OsRng); - AesSecret { key: key_bytes } - } - - /// 从已有的密钥字节创建 AesSecret 实例 - /// 输入的 key_bytes 必须是正确的长度 (例如 32 字节 for AES-256) - pub fn from_bytes(key_bytes: &[u8]) -> Result { - if key_bytes.len() != AES_KEY_SIZE { - return Err(anyhow::anyhow!( - "Invalid AES key size: expected {}, got {}", - AES_KEY_SIZE, - key_bytes.len() - )); - } - Ok(AesSecret { - key: Key::::clone_from_slice(key_bytes), - }) - } - - /// 获取 AES 密钥的 Base64 编码字符串 - pub fn get_key_as_base64(&self) -> String { - BASE64_STANDARD.encode(self.key.as_slice()) - } - - /// 使用 AES 密钥加密数据 - /// 返回: 加密后的数据 Vec与Nonce Vec拼接的 Vec - pub fn encrypt(&self, data: &[u8]) -> Result { - let cipher = Aes256Gcm::new(&self.key); - // 为每次加密生成新的、唯一的 Nonce - let nonce_bytes = Aes256Gcm::generate_nonce(&mut OsRng); // 生成随机 Nonce - let nonce_instance = Nonce::from_slice(nonce_bytes.as_slice()); - - let ciphertext = cipher.encrypt(nonce_instance, data).map_err(|e| { - log::error!("Failed to encrypt data: {:?}", e); - anyhow::anyhow!("Failed to encrypt data") - })?; - - let mut result = Vec::with_capacity(ciphertext.len() + NONCE_SIZE); - result.extend_from_slice(nonce_bytes.as_slice()); - result.extend_from_slice(&ciphertext); - - // 将结果转换为 Base64 编码字符串 - let result = BASE64_STANDARD.encode(&result); - - Ok(result) - } - - /// 使用 AES 密钥和提供的 Nonce 解密数据 - pub fn decrypt(&self, base64_data: &str) -> Result, anyhow::Error> { - // 先对Base64编码的字符串进行解码 - let data = BASE64_STANDARD.decode(base64_data).context("Failed to decode base64 data")?; - - if data.len() < NONCE_SIZE { - return Err(anyhow::anyhow!( - "Data too short: expected at least {} bytes", - NONCE_SIZE - )); - } - - let (nonce, ciphertext) = data.split_at(NONCE_SIZE); - let cipher = Aes256Gcm::new(&self.key); - let nonce_instance = Nonce::from_slice(nonce); - - cipher.decrypt(nonce_instance, ciphertext).map_err(|e| { - log::error!("Failed to decrypt data: {:?}", e); - anyhow::anyhow!("Failed to decrypt data") - }) - } -} - -/// 从 Base64 编码的密钥字符串创建 AesSecret -impl TryFrom<&str> for AesSecret { - type Error = anyhow::Error; - - fn try_from(key_base64: &str) -> Result { - let decoded_key_bytes = BASE64_STANDARD - .decode(key_base64) - .context("Failed to decode base64 key string")?; - - Self::from_bytes(&decoded_key_bytes) - } -} - -#[cfg(test)] -mod tests { - - use super::*; - - #[test] - fn test_encrypt_decrypt_workflow() { - let secret = AesSecret::new(); - let plaintext = b"Hello, secure world of AES-GCM!"; // 字节数组 - - // 加密 - let encrypt_str = secret.encrypt(plaintext).expect("Encryption failed"); - println!("encrypt_str: {}", encrypt_str); - - // 解密 - let decrypted_bytes = secret.decrypt(&encrypt_str).unwrap(); - - // 将解密后的字节数组转换回字符串 - let original_str = String::from_utf8(decrypted_bytes).unwrap(); - println!("解密后的数据: {}", original_str); - } - - #[test] - fn test_encrypt_decrypt_json_workflow() { - let secret = AesSecret::new(); - - // 创建一个要序列化的结构体 - #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)] - struct TestData { - message: String, - value: i32, - } - - let test_data = TestData { - message: "Hello, secure world of AES-GCM!".to_string(), - value: 42, - }; - - // 序列化为JSON - let json_bytes = serde_json::to_vec(&test_data).expect("Serialization failed"); - - // 加密JSON数据 - let encrypted = secret.encrypt(&json_bytes).expect("Encryption failed"); - println!("Encrypted: {}", encrypted); - - // 解密 - let decrypted = secret.decrypt(&encrypted).expect("Decryption failed"); - - // 从JSON反序列化 - let restored_data: TestData = - serde_json::from_slice(&decrypted).expect("Deserialization failed"); - println!("Restored: {:?}", restored_data); - - // 此值一致 - assert_eq!(test_data, restored_data); - } - - /// 测试加密流程 - #[test] - fn test_login_payload_encrypt() { - use crate::infrastructure::http::encryption::RsaSecret; - - let secret = AesSecret::new(); - let key = secret.get_key_as_base64(); - - // 测试通过base64编码的字符串创建AesSecret实例 - // let _ = AesSecret::try_from(key.as_str()).expect("创建AES实例失败"); - - #[derive(serde::Serialize, serde::Deserialize, Debug)] - struct LoginRequest { - email: String, - password: String, - public_secret_key: String, - } - let payload = LoginRequest { - email: "liusnew@gmail.com".to_string(), - password: "liusNew57~".to_string(), - public_secret_key: "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAseI9vA7iTxOMb5Y2xCL7BOGr1by9qEH4EfP9Bj90gxDmY8yRsVK/\no2g+i95oQxzdvdvpPAocKlQv2FEbZaqFr2Q4vy1cLrM0B1NTZl1/hGcmPSofLT9g\nmnjzP60ikY40Dxq+YXAxXZ4s2M+9thNnFr4OydacHEPEkTklcBQBglopSXc1yqHU\nARyCQ3/VxQrfh215vIPgMg2f6PH741zXFaIJjucXR8wJVySo7aZhlBTOVz5GzV0b\naWh31zA47ivXh84OXIEI+CKDUSnvsa8SCRMRs8LgaO1Xktv4yCfHHpo8Zoy+KdW0\nLJxP11G+3f9RsYRpjdmsleyHuYYS07suqwIDAQAB\n-----END RSA PUBLIC KEY-----\n".to_string(), - }; - - let json_bytes = serde_json::to_vec(&payload).expect("Serialization failed"); - let encrypted = secret.encrypt(&json_bytes).expect("Encryption failed"); - - // 使用测试内生成的密钥对,避免依赖仓库外部的公钥文件。 - let rsa_secret = RsaSecret::new().expect("生成 RSA 密钥对失败"); - let public_key = rsa_secret.get_public_key().expect("编码 RSA 公钥失败"); - let encrypted_key = RsaSecret::encrypt_with_public_key(key.as_bytes(), &public_key) - .expect("加密 AES 密钥失败"); - let decrypted_key = rsa_secret.decrypt(&encrypted_key).expect("解密 AES 密钥失败"); - assert_eq!(decrypted_key, key.as_bytes()); - - let result = serde_json::json!({ - "data": encrypted, - "encrypted": true, - "key": encrypted_key, - }); - let result = serde_json::to_string_pretty(&result).unwrap(); - log::trace!("{}", result); - } -} +use aes_gcm::{ + Aes256Gcm, Key, Nonce, + aead::{Aead, AeadCore, KeyInit, OsRng}, +}; +use anyhow::Context; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use std::fmt::Debug; + +const AES_KEY_SIZE: usize = 32; +const NONCE_SIZE: usize = 12; + +/// key本身是字节数组, 先将字节数组转换成base64编码得字符串,然后再通过非对称公钥加密再转换成base64编码的字符串 +#[derive(Debug, Clone, Default)] +pub struct AesSecret { + key: Key, // 直接存储 GenericArray 以避免重复转换 +} + +impl AesSecret { + /// 生成一个新的 AES 密钥实例 + pub fn new() -> Self { + let key_bytes = Aes256Gcm::generate_key(&mut OsRng); + AesSecret { key: key_bytes } + } + + /// 从已有的密钥字节创建 AesSecret 实例 + /// 输入的 key_bytes 必须是正确的长度 (例如 32 字节 for AES-256) + pub fn from_bytes(key_bytes: &[u8]) -> Result { + if key_bytes.len() != AES_KEY_SIZE { + return Err(anyhow::anyhow!( + "Invalid key size: expected {}, got {}", + AES_KEY_SIZE, + key_bytes.len() + )); + } + Ok(AesSecret { + key: Key::::clone_from_slice(key_bytes), + }) + } + + /// 获取 AES 密钥的 Base64 编码字符串 + pub fn get_key_as_base64(&self) -> String { + BASE64_STANDARD.encode(self.key.as_slice()) + } + + /// 使用 AES 密钥加密数据 + /// 返回: 加密后的数据 Vec与Nonce Vec拼接的 Vec + pub fn encrypt(&self, data: &[u8]) -> Result { + let cipher = Aes256Gcm::new(&self.key); + // 为每次加密生成新的、唯一的 Nonce + let nonce_bytes = Aes256Gcm::generate_nonce(&mut OsRng); // 生成随机 Nonce + let nonce_instance = Nonce::from_slice(nonce_bytes.as_slice()); + + let ciphertext = cipher.encrypt(nonce_instance, data).map_err(|e| { + tracing::error!("Failed to encrypt data: {:?}", e); + // 使用 context 来添加上下文信息 + anyhow::anyhow!("Failed to encrypt data: {:?}", e) + })?; + + let mut result = Vec::with_capacity(ciphertext.len() + NONCE_SIZE); + result.extend_from_slice(nonce_bytes.as_slice()); + result.extend_from_slice(&ciphertext); + + // 将结果转换为 Base64 编码字符串 + let result = BASE64_STANDARD.encode(&result); + + Ok(result) + } + + /// 使用 AES 密钥和提供的 Nonce 解密数据 + pub fn decrypt(&self, base64_data: &str) -> Result, anyhow::Error> { + // 先对Base64编码的字符串进行解码 + let data = BASE64_STANDARD.decode(base64_data).context("Failed to decode base64 data")?; + + if data.len() < NONCE_SIZE { + return Err(anyhow::anyhow!("Invalid data")); + } + + let (nonce, ciphertext) = data.split_at(NONCE_SIZE); + let cipher = Aes256Gcm::new(&self.key); + let nonce_instance = Nonce::from_slice(nonce); + + cipher.decrypt(nonce_instance, ciphertext).map_err(|e| { + tracing::error!("Failed to decrypt data: {:?}", e); + anyhow::anyhow!("Failed to decrypt data: {:?}", e) + }) + } +} + +/// 从 Base64 编码的密钥字符串创建 AesSecret +impl TryFrom<&str> for AesSecret { + type Error = anyhow::Error; + + fn try_from(key_base64: &str) -> Result { + let decoded_key_bytes = BASE64_STANDARD + .decode(key_base64) + .context("Failed to decode base64 key string")?; + + Self::from_bytes(&decoded_key_bytes) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_encrypt_decrypt_workflow() { + let secret = AesSecret::new(); + let plaintext = b"Hello, secure world of AES-GCM!"; // 字节数组 + + // 加密 + let encrypt_str = secret.encrypt(plaintext).expect("Encryption failed"); + println!("encrypt_str: {}", encrypt_str); + + // 解密 + let decrypted_bytes = secret.decrypt(&encrypt_str).unwrap(); + + // 将解密后的字节数组转换回字符串 + let original_str = String::from_utf8(decrypted_bytes).unwrap(); + println!("解密后的数据: {}", original_str); + } + + #[test] + fn test_encrypt_decrypt_json_workflow() { + let secret = AesSecret::new(); + + // 创建一个要序列化的结构体 + #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)] + struct TestData { + message: String, + value: i32, + } + + let test_data = TestData { + message: "Hello, secure world of AES-GCM!".to_string(), + value: 42, + }; + + // 序列化为JSON + let json_bytes = serde_json::to_vec(&test_data).expect("Serialization failed"); + + // 加密JSON数据 + let encrypted = secret.encrypt(&json_bytes).expect("Encryption failed"); + println!("Encrypted: {}", encrypted); + + // 解密 + let decrypted = secret.decrypt(&encrypted).expect("Decryption failed"); + + // 从JSON反序列化 + let restored_data: TestData = + serde_json::from_slice(&decrypted).expect("Deserialization failed"); + println!("Restored: {:?}", restored_data); + + // 确认数据一致 + assert_eq!(test_data, restored_data); + } + + /// 测试加密流程 + #[test] + fn test_login_payload_encrypt() { + use rsa::pkcs1::DecodeRsaPublicKey; + + let secret = AesSecret::new(); + let key = secret.get_key_as_base64(); + + // 测试通过base64编码的字符串创建AesSecret实例 + // let _ = AesSecret::try_from(key.as_str()).expect("创建AES实例失败"); + + #[derive(serde::Serialize, serde::Deserialize, Debug)] + struct LoginRequest { + email: String, + password: String, + public_secret_key: String, + } + let payload = LoginRequest { + email: "liusnew@gmail.com".to_string(), + password: "liusNew57~".to_string(), + public_secret_key: "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAseI9vA7iTxOMb5Y2xCL7BOGr1by9qEH4EfP9Bj90gxDmY8yRsVK/\no2g+i95oQxzdvdvpPAocKlQv2FEbZaqFr2Q4vy1cLrM0B1NTZl1/hGcmPSofLT9g\nmnjzP60ikY40Dxq+YXAxXZ4s2M+9thNnFr4OydacHEPEkTklcBQBglopSXc1yqHU\nARyCQ3/VxQrfh215vIPgMg2f6PH741zXFaIJjucXR8wJVySo7aZhlBTOVz5GzV0b\naWh31zA47ivXh84OXIEI+CKDUSnvsa8SCRMRs8LgaO1Xktv4yCfHHpo8Zoy+KdW0\nLJxP11G+3f9RsYRpjdmsleyHuYYS07suqwIDAQAB\n-----END RSA PUBLIC KEY-----\n".to_string(), + }; + + let json_bytes = serde_json::to_vec(&payload).expect("Serialization failed"); + let encrypted = secret.encrypt(&json_bytes).expect("Encryption failed"); + + // 下面连续的代码对应: crate::secret::rsa::get_rsa_secret_instance() + let public_key_path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/secret/public_key.pem"); + let public_key_str = std::fs::read(public_key_path).expect("读取公钥失败"); + let public_key = String::from_utf8(public_key_str).expect("转换公钥失败"); + let public_key = rsa::RsaPublicKey::from_pkcs1_pem(&public_key) + .map_err(|e| { + tracing::error!("Failed to parse public key: {:?}", e); + anyhow::anyhow!("Failed to parse public key: {:?}", e) + }) + .unwrap(); + let mut rng = rsa::rand_core::OsRng::default(); + let encrypted_data = public_key + .encrypt(&mut rng, rsa::Pkcs1v15Encrypt, key.as_bytes()) + .map_err(|e| { + tracing::error!("Failed to encrypt data: {:?}", e); + anyhow::anyhow!("Failed to encrypt data: {:?}", e) + }) + .unwrap(); + + // 通过一个非对称公钥加密key + let encrypted_key = base64::engine::general_purpose::STANDARD.encode(&encrypted_data); + + let result = serde_json::json!({ + "data": encrypted, + "encrypted": true, + "key": encrypted_key, + }); + let result = serde_json::to_string_pretty(&result).unwrap(); + eprintln!("{}", result); + } +} diff --git a/server/src/utils/secret/mod.rs b/server/src/utils/secret/mod.rs new file mode 100644 index 00000000..e800b7dc --- /dev/null +++ b/server/src/utils/secret/mod.rs @@ -0,0 +1,5 @@ +mod aes; +mod rsa; + +pub use aes::*; +pub use rsa::*; diff --git a/server/src/utils/secret/rsa.rs b/server/src/utils/secret/rsa.rs new file mode 100644 index 00000000..5c82c435 --- /dev/null +++ b/server/src/utils/secret/rsa.rs @@ -0,0 +1,173 @@ +use std::{fmt::Debug, fs, path::Path}; + +use base64::Engine; +use rsa::{ + Pkcs1v15Encrypt, RsaPrivateKey, + pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey, EncodeRsaPrivateKey, EncodeRsaPublicKey}, +}; +use tokio::sync::OnceCell; + +#[derive(Debug, Clone)] +pub struct RsaSecret { + pub private_key: RsaPrivateKey, + pub public_key: String, +} + +impl RsaSecret { + pub fn new(key_path: &str) -> Result { + let key_dir = Path::new(key_path); + let private_key_path = key_dir.join("private_key.pem"); + let public_key_path = key_dir.join("public_key.pem"); + + // 如果已经有密钥文件,优先复用,避免每次启动刷新 + if private_key_path.exists() && public_key_path.exists() { + let private_key_pem = fs::read_to_string(&private_key_path)?; + let private_key = RsaPrivateKey::from_pkcs1_pem(&private_key_pem)?; + let public_key_pem = fs::read_to_string(&public_key_path)?; + + return Ok(RsaSecret { + private_key, + public_key: public_key_pem, + }); + } + + // 否则生成新的密钥对 + if !key_dir.exists() { + fs::create_dir_all(key_dir)?; + } + + let mut rng = rsa::rand_core::OsRng::default(); + let bits = 2048; + let private_key = RsaPrivateKey::new(&mut rng, bits)?; + let public_key = private_key.to_public_key(); + + let private_key_pem = private_key.to_pkcs1_pem(rsa::pkcs8::LineEnding::LF)?.to_string(); + let public_key_pem = public_key.to_pkcs1_pem(rsa::pkcs8::LineEnding::LF)?; + + fs::write(&private_key_path, &private_key_pem)?; + fs::write(&public_key_path, &public_key_pem)?; + + Ok(RsaSecret { + private_key, + public_key: public_key_pem, + }) + } + + /// 获取公钥对 + pub fn get_public_key(&self) -> String { + self.public_key.clone() + } + + /// 获取私钥对 + pub fn get_private_key(&self) -> RsaPrivateKey { + self.private_key.clone() + } + + /// 解密为默认的&[u8] + pub fn decrypt(&self, data: &str) -> Result, anyhow::Error> { + // base64 解码 + let data = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| { + tracing::error!("Failed to decode base64 data: {:?}", e); + anyhow::anyhow!("Failed to decode base64 data: {:?}", e) + })?; + + let private_key = &self.private_key; + let decrypted_data = private_key.decrypt(Pkcs1v15Encrypt, &data)?; + + Ok(decrypted_data) + } + + /// 使用公钥加密 + pub fn encrypt(&self, data: &[u8]) -> Result { + let public_key = &self.private_key.to_public_key(); + + let mut rng = rsa::rand_core::OsRng::default(); + let encrypted_data = public_key.encrypt(&mut rng, Pkcs1v15Encrypt, data).map_err(|e| { + tracing::error!("Failed to encrypt data: {:?}", e); + anyhow::anyhow!("Failed to encrypt data: {:?}", e) + })?; + + let encoded_data = base64::engine::general_purpose::STANDARD.encode(&encrypted_data); + + Ok(encoded_data) + } + + /// 使用公钥加密, 公钥接收到的公钥 + pub fn encrypt_with_public_key( + &self, + data: &[u8], + public_key: &str, + ) -> Result { + let public_key = rsa::RsaPublicKey::from_pkcs1_pem(public_key).map_err(|e| { + tracing::error!("Failed to parse public key: {:?}", e); + anyhow::anyhow!("Failed to parse public key: {:?}", e) + })?; + + let mut rng = rsa::rand_core::OsRng::default(); + let encrypted_data = public_key.encrypt(&mut rng, Pkcs1v15Encrypt, data).map_err(|e| { + tracing::error!("Failed to encrypt data: {:?}", e); + anyhow::anyhow!("Failed to encrypt data: {:?}", e) + })?; + + let encoded_data = base64::engine::general_purpose::STANDARD.encode(&encrypted_data); + + Ok(encoded_data) + } +} + +/// 全局的 RSA 密钥对 +pub static RSA_SECRET_INSTANCE: OnceCell = OnceCell::const_new(); + +/// 初始化全局的 RSA 密钥对 +pub async fn init_rsa_secret(key_path: &str) -> &'static RsaSecret { + RSA_SECRET_INSTANCE + .get_or_init(|| async { RsaSecret::new(key_path).unwrap() }) + .await +} + +/// 获取全局的 RSA 密钥管理实例 +pub fn get_rsa_secret_instance() -> &'static RsaSecret { + match RSA_SECRET_INSTANCE.get() { + Some(secret_instance) => secret_instance, + None => { + tracing::debug!("RSA_SECRET_INSTANCE is not initialized"); + std::process::exit(-1); + } + } +} + +#[cfg(test)] +mod tests { + + use serde::{Deserialize, Serialize}; + + use super::*; + + #[tokio::test] + async fn test_rsa_secret_build() { + #[derive(Serialize, Deserialize, Clone, Debug)] + struct TestStruct { + name: String, + age: u32, + } + + let secret_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/secret"); + init_rsa_secret(secret_path.to_str().expect("invalid secret path")).await; + + let secret_instance = get_rsa_secret_instance(); + + let data = TestStruct { + name: "test".to_string(), + age: 18, + }; + + let data = serde_json::to_value(data).unwrap().to_string(); + let data = data.as_bytes(); + + let encrypt_data = secret_instance.encrypt(&data).unwrap(); + eprintln!("public_key: {:?}", encrypt_data); + + let res = secret_instance.decrypt(&encrypt_data).unwrap(); + eprintln!("data: {:?}", res); + } +} diff --git a/server/src/utils/storage.rs b/server/src/utils/storage.rs new file mode 100644 index 00000000..1f67535e --- /dev/null +++ b/server/src/utils/storage.rs @@ -0,0 +1,6 @@ +//! Helpers for turning stored resource paths into download URLs. +//! +//! The imported service never exposed an upload API, so object-storage +//! clients do not belong in the local-first application. + +pub mod get_objects; diff --git a/server/src/utils/storage/get_objects.rs b/server/src/utils/storage/get_objects.rs new file mode 100644 index 00000000..caaf5b53 --- /dev/null +++ b/server/src/utils/storage/get_objects.rs @@ -0,0 +1,84 @@ +//! 对象存储 URL 获取模块 +//! +//! 提供获取对象存储资源访问 URL 的功能 + +fn ensure_rooted_path(root: &str, object_path: &str) -> String { + let root = root.trim_matches('/'); + let object_path = object_path.trim_start_matches('/'); + if object_path.is_empty() { + return root.to_string(); + } + if object_path == root || object_path.starts_with(&format!("{}/", root)) { + object_path.to_string() + } else { + format!("{}/{}", root, object_path) + } +} + +/// 获取对象的完整 URL +/// +/// # Arguments +/// - `public_base_url`: 对象存储资源访问基础 URL +/// - `object_path`: 对象路径 +/// +/// # Returns +/// 返回完整的访问 URL +pub fn get_object_url(public_base_url: &str, object_path: &str) -> String { + format!( + "{}/{}", + public_base_url.trim_end_matches('/'), + object_path.trim_start_matches('/') + ) +} + +/// 获取扩展 CRX 文件的完整 URL +/// +/// # Arguments +/// - `public_base_url`: 对象存储资源访问基础 URL +/// - `extension_root`: 扩展对象根路径 +/// - `object_path`: CRX 对象路径(数据库中存储的路径) +pub fn get_extension_crx_url( + public_base_url: &str, + extension_root: &str, + object_path: &str, +) -> String { + get_object_url(public_base_url, &ensure_rooted_path(extension_root, object_path)) +} + +/// 获取扩展图标的完整 URL +/// +/// # Arguments +/// - `public_base_url`: 对象存储资源访问基础 URL +/// - `extension_root`: 扩展对象根路径 +/// - `object_path`: 图标对象路径(数据库中存储的路径) +pub fn get_extension_icon_url( + public_base_url: &str, + extension_root: &str, + object_path: &str, +) -> String { + get_object_url(public_base_url, &ensure_rooted_path(extension_root, object_path)) +} + +/// 获取头像的完整 URL +/// +/// # Arguments +/// - `public_base_url`: 对象存储资源访问基础 URL +/// - `avatar_root`: 头像对象根路径 +/// - `resource_hash`: 头像文件哈希 +pub fn get_avatar_url(public_base_url: &str, avatar_root: &str, resource_hash: &str) -> String { + get_object_url(public_base_url, &ensure_rooted_path(avatar_root, resource_hash)) +} + +/// 获取版本资源的完整 URL +/// +/// # Arguments +/// - `public_base_url`: 对象存储资源访问基础 URL +/// - `version_root`: 版本资源对象根路径 +/// - `object_path`: 版本资源对象路径(数据库中存储的路径) +pub fn get_version_resource_url( + public_base_url: &str, + version_root: &str, + object_path: &str, +) -> String { + get_object_url(public_base_url, &ensure_rooted_path(version_root, object_path)) +} diff --git a/splashscreen.html b/splashscreen.html deleted file mode 100644 index 42420ec2..00000000 --- a/splashscreen.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - Simprint - 加载中 - - - -
- - - - \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 682c846b..3408de06 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,16 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] - [[package]] name = "aes" version = "0.8.4" @@ -29,20 +19,6 @@ dependencies = [ "cpufeatures 0.2.17", ] -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "ahash" version = "0.7.8" @@ -78,6 +54,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_log-sys" version = "0.3.2" @@ -121,9 +103,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -140,7 +122,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -151,7 +133,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -228,9 +210,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -356,6 +338,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -557,6 +548,24 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "business" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "hex", + "serde", + "serde_json", + "sha2", + "sqlx", + "thiserror 2.0.17", + "tokio", + "tracing", + "url", + "uuid", +] + [[package]] name = "byte-unit" version = "5.2.0" @@ -1002,6 +1011,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1021,7 +1039,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] @@ -1062,15 +1079,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - [[package]] name = "darling" version = "0.21.3" @@ -1140,27 +1148,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "dbus" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" -dependencies = [ - "libc", - "libdbus-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "dbus-secret-service" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" -dependencies = [ - "dbus", - "zeroize", -] - [[package]] name = "deflate64" version = "0.1.10" @@ -1271,7 +1258,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1335,6 +1322,12 @@ dependencies = [ "const-random", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1382,19 +1375,8 @@ name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "embed-resource" -version = "2.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d506610004cfc74a6f5ee7e8c632b355de5eca1f03ee5e5e0ec11b77d4eb3d61" dependencies = [ - "cc", - "memchr", - "rustc_version", - "toml 0.8.2", - "vswhom", - "winreg 0.52.0", + "serde", ] [[package]] @@ -1500,7 +1482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1509,13 +1491,23 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -1584,6 +1576,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.6" @@ -1606,6 +1608,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1727,6 +1740,17 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -1976,16 +2000,6 @@ dependencies = [ "wasip3", ] -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - [[package]] name = "gio" version = "0.18.4" @@ -2185,6 +2199,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -2245,6 +2261,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -2396,7 +2421,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.57.0", ] [[package]] @@ -2744,11 +2769,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -2796,21 +2822,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "keyring" -version = "3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" -dependencies = [ - "byteorder", - "dbus-secret-service", - "log", - "security-framework 2.11.1", - "security-framework 3.7.0", - "windows-sys 0.60.2", - "zeroize", -] - [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2864,18 +2875,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.179" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" - -[[package]] -name = "libdbus-sys" -version = "0.2.7" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" -dependencies = [ - "pkg-config", -] +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -2901,13 +2903,25 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.11.1", "libc", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", ] [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -3012,6 +3026,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.6" @@ -3043,6 +3067,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3107,7 +3137,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework", "security-framework-sys", "tempfile", ] @@ -3429,6 +3459,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -3492,12 +3534,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "open" version = "5.3.3" @@ -3587,7 +3623,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.17", ] [[package]] @@ -3639,7 +3689,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -3883,9 +3933,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -3921,13 +3971,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "plist" -version = "1.8.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.32.0", "serde", "time", ] @@ -3972,18 +4022,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - [[package]] name = "portable-atomic" version = "1.13.0" @@ -4100,9 +4138,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -4142,6 +4180,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -4153,9 +4200,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -4173,9 +4220,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", "getrandom 0.3.4", @@ -4194,23 +4241,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] name = "quote" -version = "1.0.43" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -4367,21 +4414,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] -name = "read-progress-stream" -version = "1.0.0" +name = "redox_syscall" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6435842fc2fea44b528719eb8c32203bbc1bb2f5b619fbe0c0a3d8350fd8d2a8" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bytes", - "futures", - "pin-project-lite", + "bitflags 2.11.1", ] [[package]] name = "redox_syscall" -version = "0.5.18" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ "bitflags 2.11.1", ] @@ -4724,9 +4769,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -4739,15 +4784,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4787,9 +4832,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -4906,23 +4951,10 @@ dependencies = [ ] [[package]] -name = "security-framework" -version = "3.7.0" +name = "security-framework-sys" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -5224,23 +5256,18 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" name = "simprint" version = "0.1.0" dependencies = [ - "aes", - "aes-gcm", "anyhow", "axum", - "base64 0.22.1", + "business", "bytes", "chrono", "config", "directories", - "embed-resource 2.5.2", "env_logger", "futures", "hex", - "hkdf", "include_dir", "indexmap 2.13.0", - "keyring", "lazy_static", "log", "once_cell", @@ -5249,7 +5276,6 @@ dependencies = [ "reqwest", "rmcp", "rmp-serde", - "rsa", "runtime", "schemars 1.2.0", "serde", @@ -5270,7 +5296,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-store", - "tauri-plugin-upload", + "tauri-plugin-updater", "thiserror 2.0.17", "tokio", "tokio-socks", @@ -5278,7 +5304,7 @@ dependencies = [ "uuid", "windows 0.61.3", "winreg 0.55.0", - "zip", + "zip 2.4.2", ] [[package]] @@ -5304,6 +5330,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smbios-lib" @@ -5346,7 +5375,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -5384,6 +5413,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spki" @@ -5395,6 +5427,202 @@ dependencies = [ "der", ] +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.13.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.114", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.114", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.11.1", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.17", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.11.1", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.17", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.17", + "tracing", + "url", + "uuid", +] + [[package]] name = "sse-stream" version = "0.2.1" @@ -5445,6 +5673,17 @@ dependencies = [ "quote", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -5614,6 +5853,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -5928,22 +6178,35 @@ dependencies = [ ] [[package]] -name = "tauri-plugin-upload" -version = "2.4.0" +name = "tauri-plugin-updater" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e2844560c33b360506cea7289626267f43be253a01018b53bd556e86163b9fd" +checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" dependencies = [ + "base64 0.22.1", + "dirs 6.0.0", + "flate2", "futures-util", + "http", + "infer", "log", - "read-progress-stream", + "minisign-verify", + "osakit", + "percent-encoding", "reqwest", + "semver", "serde", "serde_json", + "tar", "tauri", "tauri-plugin", + "tempfile", "thiserror 2.0.17", + "time", "tokio", - "tokio-util", + "url", + "windows-sys 0.60.2", + "zip 4.6.1", ] [[package]] @@ -6043,21 +6306,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" dependencies = [ "dunce", - "embed-resource 3.0.6", + "embed-resource", "toml 0.9.10+spec-1.1.0", ] [[package]] name = "tempfile" -version = "3.24.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6496,9 +6759,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -6564,12 +6827,33 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -6588,16 +6872,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -6661,12 +6935,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.19.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", + "rand 0.10.0", "serde_core", "wasm-bindgen", ] @@ -6764,11 +7039,17 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -6779,22 +7060,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6802,9 +7080,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", @@ -6815,9 +7093,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] @@ -6926,7 +7204,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.38.4", "quote", ] @@ -6941,9 +7219,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -7005,9 +7283,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.5" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -7054,6 +7332,16 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "winapi" version = "0.3.9" @@ -7076,7 +7364,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -7157,19 +7445,6 @@ dependencies = [ "windows-strings 0.4.2", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-future" version = "0.2.1" @@ -7656,16 +7931,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "winreg" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "winreg" version = "0.55.0" @@ -7886,6 +8151,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xz2" version = "0.1.7" @@ -8114,6 +8389,18 @@ dependencies = [ "zstd", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.13.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.12" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 88fe3b66..054a04ee 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,19 +15,13 @@ default-run = "simprint" name = "simprint_lib" crate-type = ["staticlib", "cdylib", "rlib"] -[[bin]] -name = "updater" -path = "src/bin/updater.rs" - [build-dependencies] -tauri-build = { version = "2", features = [] } -embed-resource = "2.0" +tauri-build = { version = "=2.5.3", features = [] } reqwest = { version = "0.12.22", features = ["blocking", "json"] } zip = "2.2.2" config = "0.15.15" serde = { version = "1", features = ["derive"] } -aes-gcm = "0.10.3" -sha2 = "0.10.9" +serde_json = "1" [dependencies] tokio = { version = "1", features = [ @@ -41,7 +35,7 @@ tokio = { version = "1", features = [ "net", "fs", ] } -tauri = { version = "2", features = [ "tray-icon", "default", "devtools", "image-ico", "image-png", "unstable"] } +tauri = { version = "=2.9.5", features = [ "tray-icon", "default", "devtools", "image-ico", "image-png", "unstable"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" @@ -52,32 +46,26 @@ env_logger = "0.11" chrono = { version = "0.4", features = ["serde"] } tauri-plugin-log = "2.3.1" tauri-plugin-opener = "2" -tauri-plugin-upload = "2.2.1" tauri-plugin-process = "2.2.0" tauri-plugin-deep-link = "2.2.1" tauri-plugin-dialog = "2.6.0" +tauri-plugin-updater = "=2.9.0" sysinfo = "0.34.2" -base64 = "0.22.1" rand = "0.9.1" -rsa = "0.9.8" -aes-gcm = "0.10.3" bytes = "1.10.0" include_dir = "0.7.4" lazy_static = "1.5.0" -reqwest = { version = "0.12.22", features = ["multipart", "socks"] } +reqwest = { version = "0.12.22", features = ["multipart", "socks", "stream"] } tokio-socks = "0.5" tokio-util = "0.7" futures = "0.3.31" once_cell = "1.20" sha2 = "0.10.9" -config = "0.15.15" uuid = { version = "1.18.1", features = ["v4"] } hex = "0.4.3" zip = "2.2.2" axum = "0.8.4" regex = "1.12.2" -aes = "0.8" -keyring = { version = "3.6.3", features = ["windows-native", "apple-native", "sync-secret-service"] } rmcp = { version = "1.2.0", features = ["server", "macros", "schemars", "transport-streamable-http-server"] } schemars = "1.0" tauri-plugin-clipboard-manager = "2.3.2" @@ -87,8 +75,7 @@ rmp-serde = "1.3" # MessagePack 序列化 serde_bytes = "0.11" # 字节数组序列化 indexmap = { version = "2.12.0", features = ["serde"] } runtime = { path = "crates/runtime" } - -hkdf = "0.12" +business = { path = "crates/business" } [target.'cfg(windows)'.dependencies] smbios-lib = "0.9.2" @@ -120,10 +107,7 @@ unsafe_code = "warn" [lints.clippy] all = "warn" -[rustfmt] -use_small_heuristics = "Default" - [features] development = [] -production = [] +production = ["tauri/custom-protocol"] test = [] diff --git a/src-tauri/build.rs b/src-tauri/build.rs index c85f972b..73e6cff2 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -5,22 +5,25 @@ use std::path::{Path, PathBuf}; use config::Config; use serde::Deserialize; -// 引用配置加密模块(与运行时共享) -#[path = "src/core/config/encryption/key_derivation.rs"] -mod key_derivation; - -#[path = "src/core/config/encryption/crypto.rs"] -mod crypto; - // ============================================================================= // 入口:构建脚本执行流程 // ============================================================================= fn main() { + println!("cargo:rerun-if-env-changed=SIMPRINT_WEBVIEW_MODE"); + println!("cargo:rerun-if-changed=tauri.conf.json"); + + let webview_mode = + env::var("SIMPRINT_WEBVIEW_MODE").unwrap_or_else(|_| "embedBootstrapper".to_string()); + validate_selected_tauri_config(&webview_mode); + println!("cargo:rustc-env=SIMPRINT_WEBVIEW_MODE={webview_mode}"); + // 1. 仅在生产环境下下载 / 准备 webview-fixed 目录中的资源 #[cfg(feature = "production")] { - webview_assets::ensure_webview_fixed_downloaded(); + if webview_mode == "fixed-runtime" { + webview_assets::ensure_webview_fixed_downloaded(); + } } // 2. 构建 Tauri 应用(处理 Windows manifest / 权限等) @@ -28,9 +31,105 @@ fn main() { // 3. 为前端构建写入环境标记文件(.build-env) frontend_env::prepare_frontend_build_env(); +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SelectedTauriConfig { + bundle: SelectedBundleConfig, + plugins: SelectedPluginConfig, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SelectedBundleConfig { + create_updater_artifacts: bool, + windows: SelectedWindowsConfig, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SelectedWindowsConfig { + webview_install_mode: SelectedWebviewInstallMode, +} + +#[derive(Deserialize)] +struct SelectedWebviewInstallMode { + #[serde(rename = "type")] + kind: String, + path: Option, +} + +#[derive(Deserialize)] +struct SelectedPluginConfig { + updater: SelectedUpdaterConfig, +} + +#[derive(Deserialize)] +struct SelectedUpdaterConfig { + endpoints: Vec, +} - // 4. 读取明文配置并生成加密后的二进制配置文件 - config_encrypt::generate_encrypted_config(); +fn validate_selected_tauri_config(mode: &str) { + let raw = fs::read_to_string("tauri.conf.json") + .unwrap_or_else(|err| panic!("failed to read selected Tauri config: {err}")); + let config: SelectedTauriConfig = serde_json::from_str(&raw) + .unwrap_or_else(|err| panic!("failed to parse selected Tauri config: {err}")); + + let (expected_install_mode, expected_manifest) = match mode { + "embedBootstrapper" => ("embedBootstrapper", "latest.json"), + "fixed-runtime" => ("fixedRuntime", "latest-fixed.json"), + other => panic!( + "unsupported SIMPRINT_WEBVIEW_MODE '{other}'; expected embedBootstrapper or fixed-runtime" + ), + }; + + assert_eq!( + config.bundle.windows.webview_install_mode.kind, expected_install_mode, + "selected Tauri config does not match SIMPRINT_WEBVIEW_MODE '{mode}'" + ); + assert!( + config.bundle.create_updater_artifacts, + "selected Tauri config must create signed updater artifacts" + ); + + let endpoint = config + .plugins + .updater + .endpoints + .first() + .unwrap_or_else(|| panic!("selected Tauri config is missing an updater endpoint")); + assert!( + endpoint.ends_with(expected_manifest), + "updater endpoint for mode '{mode}' must end with '{expected_manifest}'" + ); + + if mode == "fixed-runtime" { + let expected_runtime_directory = fixed_runtime_directory_for_target_arch(); + let configured_path = config + .bundle + .windows + .webview_install_mode + .path + .as_deref() + .unwrap_or_else(|| panic!("fixed-runtime config is missing its WebView path")); + let normalized_path = configured_path.replace('\\', "/"); + + assert!( + normalized_path.trim_end_matches('/').ends_with(expected_runtime_directory), + "fixed-runtime path '{configured_path}' does not match target architecture directory '{expected_runtime_directory}'" + ); + } +} + +fn fixed_runtime_directory_for_target_arch() -> &'static str { + match env::var("CARGO_CFG_TARGET_ARCH").as_deref() { + Ok("x86_64") => "Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.x64", + Ok("aarch64") => "Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.arm64", + Ok("x86") => "Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.x86", + Ok(arch) => panic!("unsupported Windows target architecture '{arch}' for fixed-runtime"), + Err(err) => panic!("CARGO_CFG_TARGET_ARCH is unavailable: {err}"), + } } // ============================================================================= @@ -77,43 +176,62 @@ mod webview_assets { /// Webview 配置结构体(用于 build.rs 中解析) #[derive(Deserialize)] struct WebviewConfig { - /// 下载的 URL(注意:字段名保持与配置文件中的拼写一致:downlaod_url) - #[serde(rename = "downlaod_url")] + x86_64_download_url: String, + aarch64_download_url: String, + x86_download_url: String, + } + + struct TargetWebview { download_url: String, + runtime_directory: &'static str, } /// 确保 `webview-fixed` 目录已经从远端 ZIP 包解压完成 /// - /// - 若目录已存在,则直接跳过,不重复下载 - /// - 若目录不存在,则从指定 URL 下载 zip 并解压到 `webview-fixed/` + /// - 若当前目标架构的运行时目录已存在,则直接跳过 + /// - 否则从该架构的 URL 下载 zip,并解压到共享的 `webview-fixed/` pub fn ensure_webview_fixed_downloaded() { + println!( + "cargo:rerun-if-changed={}", + super::current_config_file_name() + ); let target_dir = Path::new("webview-fixed"); - // 若目录已存在,则认为资源已经就绪,避免每次构建都重新下载 - if target_dir.exists() { - return; - } - // 优先尝试从当前环境的配置文件中读取下载地址 - let url = detect_webview_download_url().unwrap_or_else(|| { + let target_webview = detect_target_webview().unwrap_or_else(|| { panic!( - "[BUILD ERROR] Failed to detect webview download URL from config file '{}'.\n\ - Please ensure the config file contains a valid [webview] section with 'downlaod_url' field.", + "[BUILD ERROR] Failed to detect the target WebView runtime from config file '{}'.\n\ + Please ensure [webview] contains download URLs for x86_64, aarch64 and x86.", super::current_config_file_name() ); }); - if let Err(err) = download_and_extract_webview_fixed(&url, target_dir.to_path_buf()) { + // 三种架构的运行时可以共存;仅当当前目标架构的目录已存在时才跳过下载。 + if target_dir.join(target_webview.runtime_directory).exists() { + return; + } + + if let Err(err) = download_and_extract_webview_fixed( + &target_webview.download_url, + target_dir.to_path_buf(), + ) { // 构建脚本失败时直接 panic,阻止继续构建,以避免产生不完整的产物 panic!("failed to download and extract webview-fixed assets: {err}"); } + + if !target_dir.join(target_webview.runtime_directory).exists() { + panic!( + "downloaded WebView archive does not contain expected runtime directory '{}'", + target_webview.runtime_directory + ); + } } - /// 从当前构建环境对应的 `config..toml` 中解析 `[webview]` 段的 `downlaod_url` + /// 从当前构建目标和 `config..toml` 中选择对应的 WebView 固定运行时。 /// /// 使用 config crate 进行 TOML 解析,替代手动字符串解析,提高可靠性和可维护性。 /// 解析失败时返回 `None`,由调用方决定是否回退到默认值。 - fn detect_webview_download_url() -> Option { + fn detect_target_webview() -> Option { let config_file_name = super::current_config_file_name(); // 使用 config crate 解析 TOML 文件 @@ -141,7 +259,24 @@ mod webview_assets { }) .ok()?; - Some(webview_config.download_url) + match env::var("CARGO_CFG_TARGET_ARCH").ok()?.as_str() { + "x86_64" => Some(TargetWebview { + download_url: webview_config.x86_64_download_url, + runtime_directory: super::fixed_runtime_directory_for_target_arch(), + }), + "aarch64" => Some(TargetWebview { + download_url: webview_config.aarch64_download_url, + runtime_directory: super::fixed_runtime_directory_for_target_arch(), + }), + "x86" => Some(TargetWebview { + download_url: webview_config.x86_download_url, + runtime_directory: super::fixed_runtime_directory_for_target_arch(), + }), + arch => { + eprintln!("[BUILD ERROR] Unsupported Windows target architecture: {arch}"); + None + } + } } /// 从远程下载 webview-fixed.zip 并解压到指定目录 @@ -206,71 +341,7 @@ mod webview_assets { } // ============================================================================= -// 模块三:配置加密(将 TOML 加工为加密二进制) -// ============================================================================= - -mod config_encrypt { - use super::*; - - /// 从 config.toml 生成加密后的二进制配置文件 - pub fn generate_encrypted_config() { - // 不同环境使用不同的配置文件 - // 当对应的配置文件发生变化时重新运行构建脚本 - println!("cargo:rerun-if-changed=config.development.toml"); - println!("cargo:rerun-if-changed=config.test.toml"); - println!("cargo:rerun-if-changed=config.production.toml"); - - // 根据当前构建环境选择对应的配置文件 - let config_file_name = current_config_file_name(); - - // 读取配置文件并生成加密的二进制文件,避免在可执行文件中直接出现明文配置 - let out_dir = env::var("OUT_DIR").unwrap_or_else(|e| { - panic!( - "[BUILD ERROR] OUT_DIR environment variable is not set: {}\n\ - This build script must be run by Cargo, not directly.", - e - ); - }); - - let config_path = Path::new(config_file_name); - let config_bytes = fs::read(config_path).unwrap_or_else(|e| { - panic!( - "[BUILD ERROR] Failed to read config file '{}': {}\n\ - Please ensure the config file exists and is readable.", - config_path.display(), - e - ); - }); - - std::str::from_utf8(&config_bytes).unwrap_or_else(|e| { - panic!( - "[BUILD ERROR] Config file '{}' is not valid UTF-8: {}\n\ - Please ensure workflow/local scripts write this file with UTF-8 encoding.", - config_path.display(), - e - ); - }); - - let encrypted = crypto::encrypt(&config_bytes).expect("Failed to encrypt config"); - - let out_path = Path::new(&out_dir).join("config_encrypted.bin"); - println!( - "cargo:warning=Config encrypted file path: {}", - out_path.display() - ); - fs::write(&out_path, &encrypted).unwrap_or_else(|e| { - panic!( - "[BUILD ERROR] Failed to write encrypted config to '{}': {}\n\ - Please check write permissions for OUT_DIR.", - out_path.display(), - e - ); - }); - } -} - -// ============================================================================= -// 模块四:前端构建环境标记(.build-env) +// 模块三:前端构建环境标记(.build-env) // ============================================================================= mod frontend_env { @@ -306,7 +377,7 @@ mod frontend_env { } // ============================================================================= -// 模块五:Tauri 应用构建(Windows manifest / 权限等) +// 模块四:Tauri 应用构建(Windows manifest / 权限等) // ============================================================================= mod tauri_build_pipeline { @@ -319,9 +390,6 @@ mod tauri_build_pipeline { let is_dev = true; // 暂时跳过软件管理员申请,再后续评估再决定是否需要管理员。 if !is_dev { - // 发布环境:需要管理员权限. (updater.exe manifest) - embed_resource::compile("windows/updater.rc", embed_resource::NONE); - // 发布环境:需要管理员权限. (主程序manifest) let manifest = include_str!("windows/main.manifest"); let window_attributes = @@ -335,7 +403,7 @@ mod tauri_build_pipeline { ); }); } else { - // 开发环境:不需要管理员权限. (updater.exe manifest) + // 开发环境:不需要管理员权限。 tauri_build::build(); } } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c20621a7..85192ca7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,43 +1,44 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "Capability for the main window", - "windows": ["main"], - "permissions": [ - "core:default", - "process:default", - "opener:default", - { - "identifier": "opener:allow-open-path", - "allow": [ - { "path": "$APPDATA/**" }, - { "path": "$HOME/**" }, - { "path": "$USERPROFILE/**" }, - { "path": "**" } - ] - }, - "core:window:default", - "core:window:allow-start-dragging", - "core:window:allow-minimize", - "core:window:allow-close", - "core:window:allow-destroy", - "core:window:allow-toggle-maximize", - "core:window:allow-set-focus", - "core:window:allow-show", - "core:window:allow-hide", - "core:window:allow-unminimize", - "core:window:allow-set-always-on-top", - "core:window:allow-create", - "core:webview:allow-webview-close", - "core:webview:default", - "core:webview:allow-create-webview", - "core:webview:allow-create-webview-window", - "dialog:allow-save", - "dialog:allow-open", - "clipboard-manager:allow-read-text", - "store:default", - "autostart:allow-enable", - "autostart:allow-disable", - "autostart:allow-is-enabled" - ] -} +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "process:default", + "updater:default", + "opener:default", + { + "identifier": "opener:allow-open-path", + "allow": [ + { "path": "$APPDATA/**" }, + { "path": "$HOME/**" }, + { "path": "$USERPROFILE/**" }, + { "path": "**" } + ] + }, + "core:window:default", + "core:window:allow-start-dragging", + "core:window:allow-minimize", + "core:window:allow-close", + "core:window:allow-destroy", + "core:window:allow-toggle-maximize", + "core:window:allow-set-focus", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-unminimize", + "core:window:allow-set-always-on-top", + "core:window:allow-create", + "core:webview:allow-webview-close", + "core:webview:default", + "core:webview:allow-create-webview", + "core:webview:allow-create-webview-window", + "dialog:allow-save", + "dialog:allow-open", + "clipboard-manager:allow-read-text", + "store:default", + "autostart:allow-enable", + "autostart:allow-disable", + "autostart:allow-is-enabled" + ] +} diff --git a/src-tauri/capabilities/splashscreen-capability.json b/src-tauri/capabilities/splashscreen-capability.json deleted file mode 100644 index 807ee5eb..00000000 --- a/src-tauri/capabilities/splashscreen-capability.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "splashscreen-capability", - "description": "Capability for the splashscreen window", - "windows": ["splashscreen"], - "permissions": [ - "core:default", - "process:default", - "core:event:default", - "core:window:default", - "dialog:allow-ask", - "dialog:default", - "dialog:allow-message", - "opener:allow-open-url", - "core:window:allow-show", - "core:window:allow-set-focus", - "core:window:allow-destroy", - "core:window:allow-close" - ] -} diff --git a/src-tauri/config.example.toml b/src-tauri/config.example.toml index e64ddc95..c9436261 100644 --- a/src-tauri/config.example.toml +++ b/src-tauri/config.example.toml @@ -1,12 +1,4 @@ -[server] -base_url = "https://api.simprint.app/api/" -version = "v1" -secret_key = "Nuexz9Y2hRc5Z6HK7Atb" - -[updater] -check_url = "https://update.simprint.app/api/v1/versions/check" -latest_json_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev/latest.json" -updater_temp_dir = "updates" - [webview] -downlaod_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev/webview-fixed.zip" +x86_64_download_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev/webview-fixed-x64.zip" +aarch64_download_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev/webview-fixed-arm64.zip" +x86_download_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev/webview-fixed-x86.zip" diff --git a/src-tauri/crates/business/Cargo.toml b/src-tauri/crates/business/Cargo.toml new file mode 100644 index 00000000..ddf7b09b --- /dev/null +++ b/src-tauri/crates/business/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "business" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +name = "business" +path = "src/lib.rs" + +[dependencies] +tokio = { version = "1", features = [ + "macros", + "rt-multi-thread", +] } +serde = { version = "^1.0.217", features = ["derive"] } +serde_json = "^1" +thiserror = "2" +anyhow = "1.0.93" +chrono = { version = "0.4", features = ["serde"] } +sha2 = "0.10.8" +hex = "0.4" +tracing = "0.1" +sqlx = { version = "0.8", features = [ + "sqlite", + "runtime-tokio", + "chrono", + "json", + "uuid", +] } +uuid = { version = "1.11.0", features = [ + "v4", + "fast-rng", + "macro-diagnostics", + "serde", +] } +url = "2.5.4" diff --git a/src-tauri/crates/business/LICENSE b/src-tauri/crates/business/LICENSE new file mode 100644 index 00000000..e139fc3c --- /dev/null +++ b/src-tauri/crates/business/LICENSE @@ -0,0 +1,662 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU General Public License, section +13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + + Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/src-tauri/crates/business/migrations/20250101000001_create_users.sql b/src-tauri/crates/business/migrations/20250101000001_create_users.sql new file mode 100644 index 00000000..997d6a64 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250101000001_create_users.sql @@ -0,0 +1,15 @@ +-- 创建 users 表 +-- 用户基础信息表,存储用户的基础标识信息 + +CREATE TABLE IF NOT EXISTS users ( + uuid TEXT PRIMARY KEY DEFAULT (randomblob(16)), + id VARCHAR(255) NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250101000002_create_user_infos.sql b/src-tauri/crates/business/migrations/20250101000002_create_user_infos.sql new file mode 100644 index 00000000..66d16924 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250101000002_create_user_infos.sql @@ -0,0 +1,25 @@ +-- 创建 user_infos 表 +-- 用户详细信息表,存储用户的详细业务信息 + +CREATE TABLE IF NOT EXISTS user_infos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_uuid TEXT NOT NULL UNIQUE, + nickname VARCHAR(255), + email VARCHAR(255) NOT NULL UNIQUE, + phone VARCHAR(50), + password VARCHAR(255) NOT NULL, + avatar_hash VARCHAR(255), + status VARCHAR(50) NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + CONSTRAINT fk_user_infos_user_uuid FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_infos_user_uuid ON user_infos(user_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_infos_email ON user_infos(email); +CREATE INDEX IF NOT EXISTS idx_user_infos_deleted_at ON user_infos(deleted_at); +CREATE INDEX IF NOT EXISTS idx_user_infos_status ON user_infos(status); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000001_create_teams.sql b/src-tauri/crates/business/migrations/20250120000001_create_teams.sql new file mode 100644 index 00000000..1436534a --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000001_create_teams.sql @@ -0,0 +1,33 @@ +-- 创建 teams 表 +-- 团队/工作空间表 + +CREATE TABLE IF NOT EXISTS teams ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + name VARCHAR(255) NOT NULL, + description TEXT, + -- 所有者 + owner_uuid TEXT NOT NULL, + avatar_hash VARCHAR(255), + -- 配额限制 + max_members INT NOT NULL DEFAULT 10, + max_environments INT NOT NULL DEFAULT 100, + max_proxies INT NOT NULL DEFAULT 100, + -- 【关联】团队默认代理(外键在 proxies 表创建后添加) + default_proxy_uuid TEXT, + -- 状态 + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_teams_owner FOREIGN KEY (owner_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_teams_owner_uuid ON teams(owner_uuid); +CREATE INDEX idx_teams_status ON teams(status); +CREATE INDEX idx_teams_deleted_at ON teams(deleted_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000002_create_team_members.sql b/src-tauri/crates/business/migrations/20250120000002_create_team_members.sql new file mode 100644 index 00000000..5c910995 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000002_create_team_members.sql @@ -0,0 +1,36 @@ +-- 创建 team_members 表 +-- 团队成员关联表(用户 ↔ 团队,多对多) + +CREATE TABLE IF NOT EXISTS team_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_uuid TEXT NOT NULL, + user_uuid TEXT NOT NULL, + -- 角色: owner, admin, editor, viewer + role VARCHAR(50) NOT NULL DEFAULT 'viewer', + -- 加入信息 + joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + invited_by TEXT, + -- 统计字段(冗余,提高查询性能) + environment_count INT NOT NULL DEFAULT 0, + group_count INT NOT NULL DEFAULT 0, + -- 状态 + status VARCHAR(50) NOT NULL DEFAULT 'active', + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_team_members_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_team_members_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT fk_team_members_invited_by FOREIGN KEY (invited_by) REFERENCES users(uuid), + -- 唯一约束:用户在同一团队只能有一条记录 + CONSTRAINT uk_team_members UNIQUE (team_uuid, user_uuid) +); + +-- 创建索引 +CREATE INDEX idx_team_members_team_uuid ON team_members(team_uuid); +CREATE INDEX idx_team_members_user_uuid ON team_members(user_uuid); +CREATE INDEX idx_team_members_role ON team_members(role); +CREATE INDEX idx_team_members_status ON team_members(status); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000003_create_team_invitations.sql b/src-tauri/crates/business/migrations/20250120000003_create_team_invitations.sql new file mode 100644 index 00000000..40bd23d4 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000003_create_team_invitations.sql @@ -0,0 +1,34 @@ +-- 创建 team_invitations 表 +-- 团队邀请表 + +CREATE TABLE IF NOT EXISTS team_invitations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + team_uuid TEXT NOT NULL, + email VARCHAR(255) NOT NULL, + -- 角色 + role VARCHAR(50) NOT NULL DEFAULT 'viewer', + -- 邀请者 + invited_by TEXT NOT NULL, + -- 邀请链接 + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + -- 状态: pending, accepted, rejected, expired, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'pending', + accepted_at TEXT, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_invitations_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_invitations_invited_by FOREIGN KEY (invited_by) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_invitations_team_uuid ON team_invitations(team_uuid); +CREATE INDEX idx_invitations_email ON team_invitations(email); +CREATE INDEX idx_invitations_token ON team_invitations(token); +CREATE INDEX idx_invitations_status ON team_invitations(status); +CREATE INDEX idx_invitations_expires_at ON team_invitations(expires_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000005_alter_user_infos_add_current_team.sql b/src-tauri/crates/business/migrations/20250120000005_alter_user_infos_add_current_team.sql new file mode 100644 index 00000000..2db2e1d1 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000005_alter_user_infos_add_current_team.sql @@ -0,0 +1,10 @@ +-- 修改 user_infos 表,添加当前团队字段 +-- 用于团队切换功能 + +ALTER TABLE user_infos +ADD COLUMN current_team_uuid TEXT REFERENCES teams(uuid) ON DELETE SET NULL; + +-- 添加外键约束(如果不存在) + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_user_infos_current_team ON user_infos(current_team_uuid); diff --git a/src-tauri/crates/business/migrations/20250120000006_create_groups.sql b/src-tauri/crates/business/migrations/20250120000006_create_groups.sql new file mode 100644 index 00000000..79ef3182 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000006_create_groups.sql @@ -0,0 +1,35 @@ +-- 创建 groups 表 +-- 环境分组表 + +CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL, + team_uuid TEXT, + -- 基础信息 + name VARCHAR(255) NOT NULL, + description TEXT, + color VARCHAR(50) DEFAULT 'gray', + sort_order INT DEFAULT 0, + -- 【关联】分组默认代理(外键在 proxies 表创建后添加) + default_proxy_uuid TEXT, + -- 创建者 + created_by TEXT, + -- 统计字段(计算字段) + environments_count INT DEFAULT 0, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_groups_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid), + CONSTRAINT fk_groups_created_by FOREIGN KEY (created_by) REFERENCES users(uuid) + -- 注意: default_proxy_uuid 的外键需要在 proxies 表创建后添加 +); + +-- 创建索引 +CREATE INDEX idx_groups_user_uuid ON groups(user_uuid); +CREATE INDEX idx_groups_team_uuid ON groups(team_uuid); +CREATE INDEX idx_groups_deleted_at ON groups(deleted_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000007_create_tags.sql b/src-tauri/crates/business/migrations/20250120000007_create_tags.sql new file mode 100644 index 00000000..ced29f2f --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000007_create_tags.sql @@ -0,0 +1,28 @@ +-- 创建 tags 表 +-- 标签表 + +CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL, + team_uuid TEXT, + name VARCHAR(100) NOT NULL, + color VARCHAR(50) DEFAULT 'gray', + sort_order INT DEFAULT 0, + -- 统计字段(计算字段,定期更新或触发器维护) + environments_count INT DEFAULT 0, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_tags_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_tags_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_tags_user_uuid ON tags(user_uuid); +CREATE INDEX idx_tags_team_uuid ON tags(team_uuid); +CREATE INDEX idx_tags_deleted_at ON tags(deleted_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000008_create_proxies.sql b/src-tauri/crates/business/migrations/20250120000008_create_proxies.sql new file mode 100644 index 00000000..a37dc5fa --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000008_create_proxies.sql @@ -0,0 +1,45 @@ +-- 创建 proxies 表 +-- 代理服务器表 + +CREATE TABLE IF NOT EXISTS proxies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT, + team_uuid TEXT, + -- 基础信息 + name VARCHAR(255) NOT NULL, + host VARCHAR(255) NOT NULL, + port INT NOT NULL, + proxy_type VARCHAR(50) NOT NULL DEFAULT 'http', + -- 认证信息 + username VARCHAR(255), + password TEXT, + -- SSH 类型额外字段 + ssh_key_encrypted TEXT, + ssh_passphrase_encrypted TEXT, + -- 地理位置信息 + country VARCHAR(100), + city VARCHAR(100), + -- 状态 + status VARCHAR(50) NOT NULL DEFAULT 'unknown', + latency INT, + last_check_ip VARCHAR(45), + last_checked_at TEXT, + -- 统计 + usage_count INT DEFAULT 0, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_proxies_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_proxies_user_uuid ON proxies(user_uuid); +CREATE INDEX idx_proxies_team_uuid ON proxies(team_uuid); +CREATE INDEX idx_proxies_proxy_type ON proxies(proxy_type); +CREATE INDEX idx_proxies_status ON proxies(status); +CREATE INDEX idx_proxies_deleted_at ON proxies(deleted_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000009_create_proxy_health_checks.sql b/src-tauri/crates/business/migrations/20250120000009_create_proxy_health_checks.sql new file mode 100644 index 00000000..9258f696 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000009_create_proxy_health_checks.sql @@ -0,0 +1,22 @@ +-- 创建 proxy_health_checks 表 +-- 代理健康检查记录表 + +CREATE TABLE IF NOT EXISTS proxy_health_checks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + proxy_uuid TEXT NOT NULL, + -- 检查结果 + status VARCHAR(50) NOT NULL, + latency INT, + ip_address VARCHAR(45), + error_message TEXT, + -- 时间 + checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_proxy_health_checks_proxy FOREIGN KEY (proxy_uuid) + REFERENCES proxies(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_proxy_health_checks_proxy_uuid ON proxy_health_checks(proxy_uuid); +CREATE INDEX idx_proxy_health_checks_checked_at ON proxy_health_checks(checked_at); +CREATE INDEX idx_proxy_health_checks_status ON proxy_health_checks(status); diff --git a/src-tauri/crates/business/migrations/20250120000010_create_environments.sql b/src-tauri/crates/business/migrations/20250120000010_create_environments.sql new file mode 100644 index 00000000..f9dae0e3 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000010_create_environments.sql @@ -0,0 +1,45 @@ +-- 创建 environments 表 +-- 环境基础信息表 + +CREATE TABLE IF NOT EXISTS environments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL, + team_uuid TEXT, + -- 基础信息 + name VARCHAR(255) NOT NULL, + description TEXT, + icon VARCHAR(50) DEFAULT 'chrome', + icon_color VARCHAR(50) DEFAULT 'text-gray-500', + -- 状态: ready, running, error + status VARCHAR(50) NOT NULL DEFAULT 'ready', + -- 【关联】分组 + group_uuid TEXT, + -- 【关联】代理(环境直接使用的代理,优先级高于分组默认代理) + proxy_uuid TEXT, + -- 摘要信息(用于列表显示) + system_info VARCHAR(100), + kernel_info VARCHAR(100), + fingerprint_summary VARCHAR(255), + -- 时间 + last_opened_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_environments_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_environments_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid), + CONSTRAINT fk_environments_group FOREIGN KEY (group_uuid) REFERENCES groups(uuid) ON DELETE SET NULL, + CONSTRAINT fk_environments_proxy FOREIGN KEY (proxy_uuid) REFERENCES proxies(uuid) ON DELETE SET NULL +); + +-- 创建索引 +CREATE INDEX idx_environments_user_uuid ON environments(user_uuid); +CREATE INDEX idx_environments_team_uuid ON environments(team_uuid); +CREATE INDEX idx_environments_group_uuid ON environments(group_uuid); +CREATE INDEX idx_environments_proxy_uuid ON environments(proxy_uuid); +CREATE INDEX idx_environments_status ON environments(status); +CREATE INDEX idx_environments_deleted_at ON environments(deleted_at); +CREATE INDEX idx_environments_name ON environments(name); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000011_create_environment_configs.sql b/src-tauri/crates/business/migrations/20250120000011_create_environment_configs.sql new file mode 100644 index 00000000..e14d6783 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000011_create_environment_configs.sql @@ -0,0 +1,30 @@ +-- 创建 environment_configs 表 +-- 环境完整配置表(存储 WindowConfig,与 environments 1:1) + +CREATE TABLE IF NOT EXISTS environment_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + environment_uuid TEXT NOT NULL UNIQUE, + -- WindowInfo + window_info TEXT NOT NULL DEFAULT '{}', + -- BasicSettings + basic_settings TEXT NOT NULL DEFAULT '{}', + -- AdvancedFingerprintSettings + fingerprint_settings TEXT NOT NULL DEFAULT '{}', + -- DeviceSettings + device_settings TEXT NOT NULL DEFAULT '{}', + -- PreferenceSettings + preference_settings TEXT NOT NULL DEFAULT '{}', + -- ProjectMetadata + project_metadata TEXT NOT NULL DEFAULT '{}', + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_configs_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_env_configs_env_uuid ON environment_configs(environment_uuid); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000012_create_environment_tags.sql b/src-tauri/crates/business/migrations/20250120000012_create_environment_tags.sql new file mode 100644 index 00000000..25d465da --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000012_create_environment_tags.sql @@ -0,0 +1,20 @@ +-- 创建 environment_tags 表 +-- 环境-标签关联表(多对多) + +CREATE TABLE IF NOT EXISTS environment_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + environment_uuid TEXT NOT NULL, + tag_uuid TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_tags_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE, + CONSTRAINT fk_env_tags_tag FOREIGN KEY (tag_uuid) + REFERENCES tags(uuid) ON DELETE CASCADE, + -- 唯一约束:同一环境不能重复添加同一标签 + CONSTRAINT uk_env_tags UNIQUE (environment_uuid, tag_uuid) +); + +-- 创建索引 +CREATE INDEX idx_env_tags_env_uuid ON environment_tags(environment_uuid); +CREATE INDEX idx_env_tags_tag_uuid ON environment_tags(tag_uuid); diff --git a/src-tauri/crates/business/migrations/20250120000013_create_environment_urls.sql b/src-tauri/crates/business/migrations/20250120000013_create_environment_urls.sql new file mode 100644 index 00000000..ee3648c2 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000013_create_environment_urls.sql @@ -0,0 +1,17 @@ +-- 创建 environment_urls 表 +-- 环境预设 URL 表(环境可有多个预设 URL) + +CREATE TABLE IF NOT EXISTS environment_urls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + environment_uuid TEXT NOT NULL, + url VARCHAR(2048) NOT NULL, + title VARCHAR(255), + sort_order INT DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_urls_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_env_urls_env_uuid ON environment_urls(environment_uuid); diff --git a/src-tauri/crates/business/migrations/20250120000014_create_environment_cookies.sql b/src-tauri/crates/business/migrations/20250120000014_create_environment_cookies.sql new file mode 100644 index 00000000..2e24de38 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000014_create_environment_cookies.sql @@ -0,0 +1,23 @@ +-- 创建 environment_cookies 表 +-- 环境 Cookie 表(环境可导入多个 Cookie) + +CREATE TABLE IF NOT EXISTS environment_cookies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + environment_uuid TEXT NOT NULL, + domain VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + value TEXT NOT NULL, + path VARCHAR(255) DEFAULT '/', + expires_at TEXT, + http_only INTEGER DEFAULT FALSE, + secure INTEGER DEFAULT FALSE, + same_site VARCHAR(20) DEFAULT 'Lax', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_cookies_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_env_cookies_env_uuid ON environment_cookies(environment_uuid); +CREATE INDEX idx_env_cookies_domain ON environment_cookies(domain); diff --git a/src-tauri/crates/business/migrations/20250120000015_create_templates.sql b/src-tauri/crates/business/migrations/20250120000015_create_templates.sql new file mode 100644 index 00000000..a33855c9 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000015_create_templates.sql @@ -0,0 +1,35 @@ +-- 创建 templates 表 +-- 环境配置模板表 + +CREATE TABLE IF NOT EXISTS templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL, + team_uuid TEXT, + name VARCHAR(255) NOT NULL, + description TEXT, + -- 是否公开(团队内所有人可用) + is_public INTEGER DEFAULT FALSE, + -- 摘要 + system_info VARCHAR(100), + kernel_info VARCHAR(100), + -- 完整配置(JSON 存储 WindowConfig 结构) + config_json TEXT NOT NULL DEFAULT '{}', + -- 使用统计 + usage_count INT DEFAULT 0, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_templates_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_templates_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_templates_user_uuid ON templates(user_uuid); +CREATE INDEX idx_templates_team_uuid ON templates(team_uuid); +CREATE INDEX idx_templates_is_public ON templates(is_public); +CREATE INDEX idx_templates_deleted_at ON templates(deleted_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000016_create_platform_accounts.sql b/src-tauri/crates/business/migrations/20250120000016_create_platform_accounts.sql new file mode 100644 index 00000000..4295dfc5 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000016_create_platform_accounts.sql @@ -0,0 +1,37 @@ +-- 创建 platform_accounts 表 +-- 平台账号表 + +CREATE TABLE IF NOT EXISTS platform_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL, + team_uuid TEXT, + -- 平台信息 + platform_url VARCHAR(512) NOT NULL, + platform_name VARCHAR(100), + -- 账号信息 + account VARCHAR(255) NOT NULL, + password TEXT, + -- 状态: active, inactive, expired + status VARCHAR(50) NOT NULL DEFAULT 'active', + remark TEXT, + -- 统计 + usage_count INT DEFAULT 0, + last_used_at TEXT, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_platform_accounts_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_platform_accounts_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_platform_accounts_user_uuid ON platform_accounts(user_uuid); +CREATE INDEX idx_platform_accounts_team_uuid ON platform_accounts(team_uuid); +CREATE INDEX idx_platform_accounts_platform_name ON platform_accounts(platform_name); +CREATE INDEX idx_platform_accounts_status ON platform_accounts(status); +CREATE INDEX idx_platform_accounts_deleted_at ON platform_accounts(deleted_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000017_create_environment_accounts.sql b/src-tauri/crates/business/migrations/20250120000017_create_environment_accounts.sql new file mode 100644 index 00000000..7423bb5d --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000017_create_environment_accounts.sql @@ -0,0 +1,21 @@ +-- 创建 environment_accounts 表 +-- 环境-账号关联表(多对多) + +CREATE TABLE IF NOT EXISTS environment_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + environment_uuid TEXT NOT NULL, + account_uuid TEXT NOT NULL, + sort_order INT DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_env_accounts_env FOREIGN KEY (environment_uuid) + REFERENCES environments(uuid) ON DELETE CASCADE, + CONSTRAINT fk_env_accounts_account FOREIGN KEY (account_uuid) + REFERENCES platform_accounts(uuid) ON DELETE CASCADE, + -- 唯一约束:同一环境不能重复关联同一账号 + CONSTRAINT uk_env_accounts UNIQUE (environment_uuid, account_uuid) +); + +-- 创建索引 +CREATE INDEX idx_env_accounts_env_uuid ON environment_accounts(environment_uuid); +CREATE INDEX idx_env_accounts_account_uuid ON environment_accounts(account_uuid); diff --git a/src-tauri/crates/business/migrations/20250120000034_create_rpa_tasks.sql b/src-tauri/crates/business/migrations/20250120000034_create_rpa_tasks.sql new file mode 100644 index 00000000..5c99ec4b --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000034_create_rpa_tasks.sql @@ -0,0 +1,56 @@ +-- 创建 rpa_tasks 表 +-- RPA 任务表 + +CREATE TABLE IF NOT EXISTS rpa_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL, + team_uuid TEXT, + -- 基础信息 + name VARCHAR(255) NOT NULL, + description TEXT, + tags TEXT DEFAULT '[]', + -- 触发器: manual, scheduled, event + trigger_type VARCHAR(50) NOT NULL DEFAULT 'manual', + -- 调度: hourly, daily, weekly, custom + schedule VARCHAR(50), + cron_expression VARCHAR(100), + -- 运行模式: sequential, parallel + run_mode VARCHAR(50) NOT NULL DEFAULT 'sequential', + -- 重试设置 + retry_count INT DEFAULT 0, + retry_interval INT DEFAULT 5, + -- 超时(秒) + timeout INT DEFAULT 300, + -- 并发数 + concurrency INT DEFAULT 1, + -- 错误时停止 + stop_on_error INTEGER DEFAULT TRUE, + -- 通知设置 + notify_on_complete INTEGER DEFAULT FALSE, + notify_on_error INTEGER DEFAULT TRUE, + -- 状态: idle, running, completed, failed, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'idle', + -- 统计 + run_count INT DEFAULT 0, + success_count INT DEFAULT 0, + last_run_at TEXT, + next_run_at TEXT, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_rpa_tasks_user FOREIGN KEY (user_uuid) REFERENCES users(uuid), + CONSTRAINT fk_rpa_tasks_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) +); + +-- 创建索引 +CREATE INDEX idx_rpa_tasks_user_uuid ON rpa_tasks(user_uuid); +CREATE INDEX idx_rpa_tasks_team_uuid ON rpa_tasks(team_uuid); +CREATE INDEX idx_rpa_tasks_trigger_type ON rpa_tasks(trigger_type); +CREATE INDEX idx_rpa_tasks_status ON rpa_tasks(status); +CREATE INDEX idx_rpa_tasks_next_run_at ON rpa_tasks(next_run_at); +CREATE INDEX idx_rpa_tasks_deleted_at ON rpa_tasks(deleted_at); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000035_create_rpa_task_steps.sql b/src-tauri/crates/business/migrations/20250120000035_create_rpa_task_steps.sql new file mode 100644 index 00000000..06b46f58 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000035_create_rpa_task_steps.sql @@ -0,0 +1,36 @@ +-- 创建 rpa_task_steps 表 +-- RPA 任务步骤表 + +CREATE TABLE IF NOT EXISTS rpa_task_steps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + task_uuid TEXT NOT NULL, + -- 步骤类型: navigate, click, input, wait, screenshot, script, condition, loop, scroll, keyboard, download, upload + step_type VARCHAR(50) NOT NULL, + -- 步骤名称 + name VARCHAR(255) NOT NULL, + -- 步骤配置(JSON) + config TEXT NOT NULL DEFAULT '{}', + -- 是否启用 + enabled INTEGER DEFAULT TRUE, + -- 画布位置 + position_x INT DEFAULT 0, + position_y INT DEFAULT 0, + -- 排序 + sort_order INT DEFAULT 0, + -- 连接到的下一个步骤 + next_step_uuid TEXT, + -- 条件分支(条件类型步骤) + branch_config TEXT, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_rpa_task_steps_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_rpa_task_steps_task_uuid ON rpa_task_steps(task_uuid); +CREATE INDEX idx_rpa_task_steps_sort_order ON rpa_task_steps(sort_order); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000036_create_rpa_task_environments.sql b/src-tauri/crates/business/migrations/20250120000036_create_rpa_task_environments.sql new file mode 100644 index 00000000..bdd4c008 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000036_create_rpa_task_environments.sql @@ -0,0 +1,19 @@ +-- 创建 rpa_task_environments 表 +-- RPA 任务-环境关联表(多对多) + +CREATE TABLE IF NOT EXISTS rpa_task_environments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_uuid TEXT NOT NULL, + environment_uuid TEXT NOT NULL, + sort_order INT DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_rpa_task_env_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE, + CONSTRAINT fk_rpa_task_env_env FOREIGN KEY (environment_uuid) REFERENCES environments(uuid) ON DELETE CASCADE, + -- 唯一约束 + CONSTRAINT uk_rpa_task_environments UNIQUE (task_uuid, environment_uuid) +); + +-- 创建索引 +CREATE INDEX idx_rpa_task_env_task_uuid ON rpa_task_environments(task_uuid); +CREATE INDEX idx_rpa_task_env_env_uuid ON rpa_task_environments(environment_uuid); diff --git a/src-tauri/crates/business/migrations/20250120000037_create_rpa_task_runs.sql b/src-tauri/crates/business/migrations/20250120000037_create_rpa_task_runs.sql new file mode 100644 index 00000000..26f67ad3 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000037_create_rpa_task_runs.sql @@ -0,0 +1,31 @@ +-- 创建 rpa_task_runs 表 +-- RPA 任务执行记录表 + +CREATE TABLE IF NOT EXISTS rpa_task_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + task_uuid TEXT NOT NULL, + -- 状态: running, completed, failed, cancelled + status VARCHAR(50) NOT NULL DEFAULT 'running', + -- 步骤统计 + total_steps INT NOT NULL DEFAULT 0, + completed_steps INT NOT NULL DEFAULT 0, + failed_steps INT NOT NULL DEFAULT 0, + -- 执行时间 + started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at TEXT, + duration_ms BIGINT, + -- 结果摘要 + result_summary TEXT, + -- 错误信息 + error_message TEXT, + -- 执行日志(JSON 数组) + logs TEXT DEFAULT '[]', + -- 约束 + CONSTRAINT fk_rpa_task_runs_task FOREIGN KEY (task_uuid) REFERENCES rpa_tasks(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_rpa_task_runs_task_uuid ON rpa_task_runs(task_uuid); +CREATE INDEX idx_rpa_task_runs_status ON rpa_task_runs(status); +CREATE INDEX idx_rpa_task_runs_started_at ON rpa_task_runs(started_at); diff --git a/src-tauri/crates/business/migrations/20250120000045_create_audit_logs.sql b/src-tauri/crates/business/migrations/20250120000045_create_audit_logs.sql new file mode 100644 index 00000000..417d8414 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000045_create_audit_logs.sql @@ -0,0 +1,36 @@ +-- 创建 audit_logs 表 +-- 审计日志表 + +CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL, + team_uuid TEXT, + -- 操作类型: login, logout, password_change, create, update, delete, batch_delete, start, stop, import, export, invite, role_change, member_remove, settings_update + action VARCHAR(50) NOT NULL, + -- 目标类型: environment, group, tag, proxy, account, team, settings, system + target_type VARCHAR(50) NOT NULL, + -- 目标 ID + target_uuid TEXT, + target_name VARCHAR(255), + -- 详情 + details TEXT, + -- 变更内容(JSON) + changes TEXT, + -- 请求信息 + ip_address VARCHAR(45), + user_agent TEXT, + request_id VARCHAR(100), + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_audit_logs_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_audit_logs_user_uuid ON audit_logs(user_uuid); +CREATE INDEX idx_audit_logs_team_uuid ON audit_logs(team_uuid); +CREATE INDEX idx_audit_logs_action ON audit_logs(action); +CREATE INDEX idx_audit_logs_target_type ON audit_logs(target_type); +CREATE INDEX idx_audit_logs_target_uuid ON audit_logs(target_uuid); +CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); diff --git a/src-tauri/crates/business/migrations/20250120000047_create_user_preferences.sql b/src-tauri/crates/business/migrations/20250120000047_create_user_preferences.sql new file mode 100644 index 00000000..f2039a01 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000047_create_user_preferences.sql @@ -0,0 +1,23 @@ +-- 创建 user_preferences 表 +-- 用户偏好设置表(仅云同步设置) + +CREATE TABLE IF NOT EXISTS user_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_uuid TEXT NOT NULL UNIQUE, + -- 主题: light, dark, system + theme VARCHAR(50) NOT NULL DEFAULT 'system', + -- 语言 + language VARCHAR(20) NOT NULL DEFAULT 'zh-CN', + -- 通知开关 + notifications_enabled INTEGER NOT NULL DEFAULT TRUE, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_user_preferences_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_user_preferences_user_uuid ON user_preferences(user_uuid); + +-- 创建更新时间触发器 diff --git a/src-tauri/crates/business/migrations/20250120000049_add_foreign_keys.sql b/src-tauri/crates/business/migrations/20250120000049_add_foreign_keys.sql new file mode 100644 index 00000000..a7706707 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000049_add_foreign_keys.sql @@ -0,0 +1,8 @@ +-- 添加延迟创建的外键约束 +-- 某些外键需要在相关表都创建后才能添加 + +-- teams.default_proxy_uuid -> proxies.uuid + +-- groups.default_proxy_uuid -> proxies.uuid + +-- 创建 groups.default_proxy_uuid 索引 diff --git a/src-tauri/crates/business/migrations/20250120000051_remove_icon_and_color_fields.sql b/src-tauri/crates/business/migrations/20250120000051_remove_icon_and_color_fields.sql new file mode 100644 index 00000000..39cb9955 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250120000051_remove_icon_and_color_fields.sql @@ -0,0 +1,9 @@ +-- 移除环境的 icon 和 icon_color 字段 +-- 移除分组的 color 字段 + +-- 移除 environments 表的 icon 和 icon_color 字段 +ALTER TABLE environments DROP COLUMN icon; +ALTER TABLE environments DROP COLUMN icon_color; + +-- 移除 groups 表的 color 字段 +ALTER TABLE groups DROP COLUMN color; diff --git a/src-tauri/crates/business/migrations/20250124000001_create_messages.sql b/src-tauri/crates/business/migrations/20250124000001_create_messages.sql new file mode 100644 index 00000000..0dd8ed76 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250124000001_create_messages.sql @@ -0,0 +1,138 @@ +-- 创建 messages 表 +-- 消息表,用于存储系统消息、团队邀请通知等 + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + + -- 消息类型 + message_type VARCHAR(50) NOT NULL, + -- 可选值: + -- 'private_chat' - 用户私信 + -- 'team_announcement' - 团队公告 + -- 'team_invitation' - 团队邀请 + -- 'team_removal' - 团队移除成员 + -- 'system_notification' - 系统通知 + + -- 消息内容 + title VARCHAR(255) NOT NULL, -- 消息标题 + content TEXT, -- 消息内容(支持 JSON 格式存储扩展数据) + + -- 发送者 + sender_uuid TEXT, -- 发送者 TEXT(系统消息可为 NULL) + + -- 接收者模式 + recipient_type VARCHAR(20) NOT NULL DEFAULT 'single', + -- 'single' - 单个用户 + -- 'multiple' - 多个指定用户 + -- 'team' - 团队内所有成员 + -- 'all' - 所有用户(系统广播) + + -- 关联资源(根据消息类型关联不同的资源) + related_type VARCHAR(50), -- 关联类型:team, invitation, etc. + related_uuid TEXT, -- 关联资源的 TEXT(如 team_uuid, invitation_uuid) + + -- 消息元数据(JSON 格式,存储扩展信息) + metadata TEXT, + -- 例如:{ + -- "team_name": "研发团队", + -- "inviter_name": "张三", + -- "role": "editor" + -- } + + -- 消息状态 + status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, deleted + priority VARCHAR(20) NOT NULL DEFAULT 'normal', -- low, normal, high, urgent + + -- 时间戳 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + + -- 外键约束 + CONSTRAINT fk_messages_sender FOREIGN KEY (sender_uuid) + REFERENCES users(uuid) ON DELETE SET NULL +); + +-- 创建 user_messages 表(用户消息关联表,支持多接收者) +CREATE TABLE IF NOT EXISTS user_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_uuid TEXT NOT NULL, + user_uuid TEXT NOT NULL, + + -- 阅读状态 + is_read INTEGER NOT NULL DEFAULT FALSE, + read_at TEXT, + + -- 操作状态(用于邀请、移除等需要操作的消息) + action_status VARCHAR(20), + -- 'pending' - 待处理(邀请类消息) + -- 'accepted' - 已接受 + -- 'rejected' - 已拒绝 + -- 'expired' - 已过期 + -- NULL - 无需操作的消息 + + action_at TEXT, + + -- 时间戳 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- 外键约束 + CONSTRAINT fk_user_messages_message FOREIGN KEY (message_uuid) + REFERENCES messages(uuid) ON DELETE CASCADE, + CONSTRAINT fk_user_messages_user FOREIGN KEY (user_uuid) + REFERENCES users(uuid) ON DELETE CASCADE, + + -- 唯一约束:一个用户对一条消息只能有一条记录 + CONSTRAINT uk_user_messages_message_user UNIQUE (message_uuid, user_uuid) +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_messages_type ON messages(message_type); +CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_uuid); +CREATE INDEX IF NOT EXISTS idx_messages_related ON messages(related_type, related_uuid); +CREATE INDEX IF NOT EXISTS idx_messages_recipient_type ON messages(recipient_type); +CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status); +CREATE INDEX IF NOT EXISTS idx_messages_priority ON messages(priority); +CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_messages_deleted_at ON messages(deleted_at); + +CREATE INDEX IF NOT EXISTS idx_user_messages_user ON user_messages(user_uuid); +CREATE INDEX IF NOT EXISTS idx_user_messages_message ON user_messages(message_uuid); +CREATE INDEX IF NOT EXISTS idx_user_messages_is_read ON user_messages(user_uuid, is_read); +CREATE INDEX IF NOT EXISTS idx_user_messages_action_status ON user_messages(user_uuid, action_status); +CREATE INDEX IF NOT EXISTS idx_user_messages_user_unread ON user_messages(user_uuid, is_read) + WHERE is_read = FALSE; + +-- 复合索引:用于查询用户未读消息 +CREATE INDEX IF NOT EXISTS idx_user_messages_user_type_unread + ON user_messages(user_uuid, is_read, created_at DESC) + WHERE is_read = FALSE; + +-- 创建更新时间触发器 + + +-- 团队消息自动分发触发器 +-- 当创建 recipient_type='team' 的消息时,自动为团队成员创建 user_messages 记录 +CREATE TRIGGER trigger_auto_create_team_message_recipients + AFTER INSERT ON messages + FOR EACH ROW + WHEN NEW.recipient_type = 'team' + AND NEW.related_type = 'team' + AND NEW.related_uuid IS NOT NULL +BEGIN + -- 如果接收者类型是 team,且有关联的团队 TEXT + -- 为团队所有活跃成员创建消息关联记录 + INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) + SELECT NEW.uuid, tm.user_uuid, FALSE, + CASE + WHEN NEW.message_type = 'team_invitation' THEN 'pending' + ELSE NULL + END + FROM team_members tm + WHERE tm.team_uuid = NEW.related_uuid + AND tm.status = 'active' + AND tm.deleted_at IS NULL + ON CONFLICT (message_uuid, user_uuid) DO NOTHING; +END; diff --git a/src-tauri/crates/business/migrations/20250124000002_add_deleted_at_to_team_invitations.sql b/src-tauri/crates/business/migrations/20250124000002_add_deleted_at_to_team_invitations.sql new file mode 100644 index 00000000..b5a05dd5 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250124000002_add_deleted_at_to_team_invitations.sql @@ -0,0 +1,8 @@ +-- 为 team_invitations 表添加 deleted_at 字段 +-- 用于软删除功能 + +ALTER TABLE team_invitations +ADD COLUMN deleted_at TEXT; + +-- 创建索引以优化查询性能 +CREATE INDEX IF NOT EXISTS idx_invitations_deleted_at ON team_invitations(deleted_at) WHERE deleted_at IS NULL; diff --git a/src-tauri/crates/business/migrations/20250125000001_create_workspaces.sql b/src-tauri/crates/business/migrations/20250125000001_create_workspaces.sql new file mode 100644 index 00000000..d3b923f5 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000001_create_workspaces.sql @@ -0,0 +1,23 @@ +-- 创建 workspaces 表 +-- 工作空间表,资源隔离的顶层容器 + +CREATE TABLE IF NOT EXISTS workspaces ( + uuid TEXT PRIMARY KEY DEFAULT (randomblob(16)), + name VARCHAR(255) NOT NULL, + owner_uuid TEXT NOT NULL, + workspace_type VARCHAR(50) NOT NULL DEFAULT 'personal', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + -- 约束 + CONSTRAINT fk_workspaces_owner FOREIGN KEY (owner_uuid) REFERENCES users(uuid) +); + +-- 创建索引 +CREATE INDEX idx_workspaces_owner_uuid ON workspaces(owner_uuid); +CREATE INDEX idx_workspaces_deleted_at ON workspaces(deleted_at); +CREATE INDEX idx_workspaces_workspace_type ON workspaces(workspace_type); + +-- 创建更新时间触发器 + +-- 列注释 diff --git a/src-tauri/crates/business/migrations/20250125000002_create_workspace_quotas.sql b/src-tauri/crates/business/migrations/20250125000002_create_workspace_quotas.sql new file mode 100644 index 00000000..6861aa49 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000002_create_workspace_quotas.sql @@ -0,0 +1,30 @@ +-- 创建 workspace_quotas 表 +-- 工作空间配额表,定义工作空间的资源配额限制 + +CREATE TABLE IF NOT EXISTS workspace_quotas ( + workspace_uuid TEXT PRIMARY KEY, + -- 环境配额 + max_environments INT NOT NULL DEFAULT 10, + used_environments INT NOT NULL DEFAULT 0, + -- 团队成员配额(所有团队总和) + max_team_members INT NOT NULL DEFAULT 5, + used_team_members INT NOT NULL DEFAULT 0, + -- 代理配额 + max_proxies INT NOT NULL DEFAULT 10, + used_proxies INT NOT NULL DEFAULT 0, + -- RPA 任务配额 + max_rpa_tasks INT NOT NULL DEFAULT 5, + used_rpa_tasks INT NOT NULL DEFAULT 0, + -- 时间 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_workspace_quotas_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE +); + +-- 创建索引 +CREATE INDEX idx_workspace_quotas_workspace_uuid ON workspace_quotas(workspace_uuid); + +-- 创建更新时间触发器 + +-- 列注释 diff --git a/src-tauri/crates/business/migrations/20250125000003_create_proxy_visible_teams.sql b/src-tauri/crates/business/migrations/20250125000003_create_proxy_visible_teams.sql new file mode 100644 index 00000000..a449548f --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000003_create_proxy_visible_teams.sql @@ -0,0 +1,22 @@ +-- 创建 proxy_visible_teams 表 +-- 代理可见团队关联表,控制代理对哪些团队可见 + +CREATE TABLE IF NOT EXISTS proxy_visible_teams ( + proxy_uuid TEXT NOT NULL, + workspace_uuid TEXT NOT NULL, + team_uuid TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_proxy_visible_teams_proxy FOREIGN KEY (proxy_uuid) REFERENCES proxies(uuid) ON DELETE CASCADE, + CONSTRAINT fk_proxy_visible_teams_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE, + CONSTRAINT fk_proxy_visible_teams_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + -- 唯一约束:一个代理对一个团队只能有一条可见性记录 + CONSTRAINT uk_proxy_visible_teams UNIQUE (proxy_uuid, team_uuid) +); + +-- 创建索引 +CREATE INDEX idx_proxy_visible_teams_proxy_uuid ON proxy_visible_teams(proxy_uuid); +CREATE INDEX idx_proxy_visible_teams_team_uuid ON proxy_visible_teams(team_uuid); +CREATE INDEX idx_proxy_visible_teams_workspace_uuid ON proxy_visible_teams(workspace_uuid); + +-- 列注释 diff --git a/src-tauri/crates/business/migrations/20250125000004_create_group_member_permissions.sql b/src-tauri/crates/business/migrations/20250125000004_create_group_member_permissions.sql new file mode 100644 index 00000000..43b56814 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000004_create_group_member_permissions.sql @@ -0,0 +1,33 @@ +-- 创建 group_member_permissions 表 +-- 分组权限表,控制团队成员对分组的访问权限 + +CREATE TABLE IF NOT EXISTS group_member_permissions ( + group_uuid TEXT NOT NULL, + workspace_uuid TEXT NOT NULL, + team_uuid TEXT NOT NULL, + user_uuid TEXT NOT NULL, + permission_type VARCHAR(50) NOT NULL DEFAULT 'read', + granted_by TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 约束 + CONSTRAINT fk_group_member_permissions_group FOREIGN KEY (group_uuid) REFERENCES groups(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_workspace FOREIGN KEY (workspace_uuid) REFERENCES workspaces(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_team FOREIGN KEY (team_uuid) REFERENCES teams(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT fk_group_member_permissions_granted_by FOREIGN KEY (granted_by) REFERENCES users(uuid), + -- 唯一约束:一个用户对一个分组只能有一条权限记录 + CONSTRAINT uk_group_member_permissions UNIQUE (group_uuid, user_uuid), + -- 检查约束:权限类型必须是 read/write/manage 之一 + CONSTRAINT ck_group_member_permissions_type CHECK (permission_type IN ('read', 'write', 'manage')) +); + +-- 创建索引 +CREATE INDEX idx_group_member_permissions_group_uuid ON group_member_permissions(group_uuid); +CREATE INDEX idx_group_member_permissions_user_uuid ON group_member_permissions(user_uuid); +CREATE INDEX idx_group_member_permissions_workspace_uuid ON group_member_permissions(workspace_uuid); +CREATE INDEX idx_group_member_permissions_team_uuid ON group_member_permissions(team_uuid); + +-- 创建更新时间触发器 + +-- 列注释 diff --git a/src-tauri/crates/business/migrations/20250125000005_alter_teams_add_workspace.sql b/src-tauri/crates/business/migrations/20250125000005_alter_teams_add_workspace.sql new file mode 100644 index 00000000..018985b9 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000005_alter_teams_add_workspace.sql @@ -0,0 +1,18 @@ +-- 修改 teams 表,添加工作空间支持 +-- 添加 workspace_uuid,移除配额相关字段和默认代理 + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE teams ADD COLUMN workspace_uuid TEXT REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_teams_workspace_uuid ON teams(workspace_uuid); + +-- 移除配额相关字段(配额移至 workspace_quotas) +ALTER TABLE teams DROP COLUMN max_members; +ALTER TABLE teams DROP COLUMN max_environments; +ALTER TABLE teams DROP COLUMN max_proxies; + +-- 移除默认代理字段(不再需要默认代理) +ALTER TABLE teams DROP COLUMN default_proxy_uuid; + +-- 注意:workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 diff --git a/src-tauri/crates/business/migrations/20250125000006_alter_team_members_add_workspace.sql b/src-tauri/crates/business/migrations/20250125000006_alter_team_members_add_workspace.sql new file mode 100644 index 00000000..42eaef95 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000006_alter_team_members_add_workspace.sql @@ -0,0 +1,20 @@ +-- 修改 team_members 表,添加工作空间支持 +-- 添加 workspace_uuid(冗余字段),移除统计字段 + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE team_members ADD COLUMN workspace_uuid TEXT REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_team_members_workspace_uuid ON team_members(workspace_uuid); + +-- 移除统计字段(可通过查询计算) +ALTER TABLE team_members DROP COLUMN environment_count; +ALTER TABLE team_members DROP COLUMN group_count; + +-- 删除旧的唯一约束 + +-- 添加新的唯一约束(包含 workspace_uuid) +CREATE UNIQUE INDEX IF NOT EXISTS uk_team_members_workspace +ON team_members(team_uuid, user_uuid, workspace_uuid); + +-- 注意:workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 diff --git a/src-tauri/crates/business/migrations/20250125000007_alter_groups_add_workspace.sql b/src-tauri/crates/business/migrations/20250125000007_alter_groups_add_workspace.sql new file mode 100644 index 00000000..4c4e3986 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000007_alter_groups_add_workspace.sql @@ -0,0 +1,23 @@ +-- 修改 groups 表,添加工作空间支持 +-- 添加 workspace_uuid,移除 user_uuid, default_proxy_uuid, color,确保 team_uuid NOT NULL + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE groups ADD COLUMN workspace_uuid TEXT REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_groups_workspace_uuid ON groups(workspace_uuid); + +-- 移除 user_uuid(分组属于团队,不再直接属于用户) +DROP INDEX IF EXISTS idx_groups_user_uuid; +ALTER TABLE groups DROP COLUMN user_uuid; + +-- 移除默认代理字段(不再需要默认代理) +ALTER TABLE groups DROP COLUMN default_proxy_uuid; + +-- 移除 color 字段(简化设计) + +-- 删除旧的索引 + +-- 注意: +-- 1. workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 +-- 2. team_uuid 的 NOT NULL 约束将在数据迁移后添加(确保所有数据都有 team_uuid) diff --git a/src-tauri/crates/business/migrations/20250125000008_alter_environments_add_workspace.sql b/src-tauri/crates/business/migrations/20250125000008_alter_environments_add_workspace.sql new file mode 100644 index 00000000..7e58b8d5 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000008_alter_environments_add_workspace.sql @@ -0,0 +1,12 @@ +-- 修改 environments 表,添加工作空间支持 +-- 添加 workspace_uuid,确保 team_uuid NOT NULL + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE environments ADD COLUMN workspace_uuid TEXT REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_environments_workspace_uuid ON environments(workspace_uuid); + +-- 注意: +-- 1. workspace_uuid 的外键约束和数据填充将在数据迁移脚本中完成 +-- 2. team_uuid 的 NOT NULL 约束将在数据迁移后添加(确保所有数据都有 team_uuid) diff --git a/src-tauri/crates/business/migrations/20250125000009_alter_proxies_add_workspace.sql b/src-tauri/crates/business/migrations/20250125000009_alter_proxies_add_workspace.sql new file mode 100644 index 00000000..60d50759 --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000009_alter_proxies_add_workspace.sql @@ -0,0 +1,28 @@ +-- 修改 proxies 表,添加工作空间支持 +-- 添加 workspace_uuid 和 owner_uuid(重命名自 user_uuid),移除 team_uuid, usage_count + +-- 添加 workspace_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE proxies ADD COLUMN workspace_uuid TEXT REFERENCES workspaces(uuid) ON DELETE CASCADE; + +-- 添加 owner_uuid 列(先允许 NULL,数据迁移后再设置为 NOT NULL) +ALTER TABLE proxies ADD COLUMN owner_uuid TEXT REFERENCES users(uuid) ON DELETE CASCADE; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_proxies_workspace_uuid ON proxies(workspace_uuid); +CREATE INDEX IF NOT EXISTS idx_proxies_owner_uuid ON proxies(owner_uuid); + +-- 将 user_uuid 的数据复制到 owner_uuid(如果 owner_uuid 为空) +UPDATE proxies SET owner_uuid = user_uuid WHERE owner_uuid IS NULL; + +-- 移除 team_uuid(代理属于工作空间,不属于团队) +DROP INDEX IF EXISTS idx_proxies_team_uuid; +ALTER TABLE proxies DROP COLUMN team_uuid; + +-- 移除 usage_count(可通过查询计算) +ALTER TABLE proxies DROP COLUMN usage_count; + +-- 删除旧的索引 + +-- 注意: +-- 1. workspace_uuid 和 owner_uuid 的外键约束将在数据迁移脚本中完成 +-- 2. user_uuid 列将在数据迁移后删除(迁移到 owner_uuid) diff --git a/src-tauri/crates/business/migrations/20250125000013_alter_user_infos_add_current_workspace.sql b/src-tauri/crates/business/migrations/20250125000013_alter_user_infos_add_current_workspace.sql new file mode 100644 index 00000000..2cbd933f --- /dev/null +++ b/src-tauri/crates/business/migrations/20250125000013_alter_user_infos_add_current_workspace.sql @@ -0,0 +1,32 @@ +-- 修改 user_infos 表,添加当前工作空间字段 +-- 用于工作空间切换功能 + +ALTER TABLE user_infos +ADD COLUMN current_workspace_uuid TEXT REFERENCES workspaces(uuid) ON DELETE SET NULL; + +-- 添加外键约束(如果不存在) + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_user_infos_current_workspace ON user_infos(current_workspace_uuid); + +-- 从当前团队的工作空间初始化 current_workspace_uuid +UPDATE user_infos +SET current_workspace_uuid = ( + SELECT t.workspace_uuid FROM teams t + WHERE t.uuid = user_infos.current_team_uuid + AND t.deleted_at IS NULL + LIMIT 1 +) +WHERE current_team_uuid IS NOT NULL + AND current_workspace_uuid IS NULL; + +-- 对于没有当前团队的用户,使用其个人工作空间 +UPDATE user_infos +SET current_workspace_uuid = ( + SELECT w.uuid FROM workspaces w + WHERE w.owner_uuid = user_infos.user_uuid + AND w.workspace_type = 'personal' + AND w.deleted_at IS NULL + LIMIT 1 +) +WHERE current_workspace_uuid IS NULL; diff --git a/src-tauri/crates/business/migrations/20260316000001_create_local_api_service_tables.sql b/src-tauri/crates/business/migrations/20260316000001_create_local_api_service_tables.sql new file mode 100644 index 00000000..bd821bf2 --- /dev/null +++ b/src-tauri/crates/business/migrations/20260316000001_create_local_api_service_tables.sql @@ -0,0 +1,100 @@ +-- 创建本地 API 服务相关表 + +CREATE TABLE IF NOT EXISTS user_local_api_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT FALSE, + port INTEGER NOT NULL DEFAULT 8080, + remote_access INTEGER NOT NULL DEFAULT FALSE, + cors_origins TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + CONSTRAINT fk_user_local_api_settings_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT chk_user_local_api_settings_port CHECK (port BETWEEN 1 AND 65535), + CONSTRAINT chk_user_local_api_settings_cors_origins CHECK (json_type(cors_origins) = 'array') +); + +CREATE INDEX idx_user_local_api_settings_user_uuid + ON user_local_api_settings(user_uuid); + + +CREATE TABLE IF NOT EXISTS user_local_api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + user_uuid TEXT NOT NULL, + key_prefix VARCHAR(32) NOT NULL, + key_hash VARCHAR(128) NOT NULL, + masked_key VARCHAR(64) NOT NULL, + is_active INTEGER NOT NULL DEFAULT TRUE, + requests_today INTEGER NOT NULL DEFAULT 0, + daily_limit INTEGER NOT NULL DEFAULT 1000, + last_reset_date DATE NOT NULL DEFAULT CURRENT_DATE, + last_used_at TEXT, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + CONSTRAINT fk_user_local_api_keys_user FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT chk_user_local_api_keys_requests_today CHECK (requests_today >= 0), + CONSTRAINT chk_user_local_api_keys_daily_limit CHECK (daily_limit >= 0) +); + +CREATE INDEX idx_user_local_api_keys_user_uuid + ON user_local_api_keys(user_uuid); + +CREATE INDEX idx_user_local_api_keys_key_prefix + ON user_local_api_keys(key_prefix); + +CREATE INDEX idx_user_local_api_keys_key_hash + ON user_local_api_keys(key_hash); + +CREATE UNIQUE INDEX idx_user_local_api_keys_active_user + ON user_local_api_keys(user_uuid) + WHERE is_active = TRUE AND deleted_at IS NULL; + + +CREATE TABLE IF NOT EXISTS user_local_api_key_permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + api_key_id INTEGER NOT NULL, + permission_code VARCHAR(128) NOT NULL, + is_enabled INTEGER NOT NULL DEFAULT TRUE, + rate_limit_per_minute INTEGER NOT NULL DEFAULT 60, + rate_limit_per_hour INTEGER NOT NULL DEFAULT 1000, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + CONSTRAINT fk_user_local_api_key_permissions_key FOREIGN KEY (api_key_id) REFERENCES user_local_api_keys(id) ON DELETE CASCADE, + CONSTRAINT uq_user_local_api_key_permissions UNIQUE (api_key_id, permission_code), + CONSTRAINT chk_user_local_api_key_permissions_minute CHECK (rate_limit_per_minute >= 0), + CONSTRAINT chk_user_local_api_key_permissions_hour CHECK (rate_limit_per_hour >= 0) +); + +CREATE INDEX idx_user_local_api_key_permissions_key + ON user_local_api_key_permissions(api_key_id); + +CREATE INDEX idx_user_local_api_key_permissions_code + ON user_local_api_key_permissions(permission_code); + + +CREATE TABLE IF NOT EXISTS user_local_api_request_counters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + api_key_id INTEGER NOT NULL, + permission_code VARCHAR(128) NOT NULL, + window_type VARCHAR(16) NOT NULL, + window_start TEXT NOT NULL, + request_count INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_user_local_api_request_counters_key FOREIGN KEY (api_key_id) REFERENCES user_local_api_keys(id) ON DELETE CASCADE, + CONSTRAINT uq_user_local_api_request_counters UNIQUE (api_key_id, permission_code, window_type, window_start), + CONSTRAINT chk_user_local_api_request_counters_window_type CHECK (window_type IN ('minute', 'hour', 'day')), + CONSTRAINT chk_user_local_api_request_counters_request_count CHECK (request_count >= 0) +); + +CREATE INDEX idx_user_local_api_request_counters_key + ON user_local_api_request_counters(api_key_id); + +CREATE INDEX idx_user_local_api_request_counters_lookup + ON user_local_api_request_counters(api_key_id, permission_code, window_type, window_start); diff --git a/src-tauri/crates/business/migrations/20260316000003_create_local_api_permission_definitions.sql b/src-tauri/crates/business/migrations/20260316000003_create_local_api_permission_definitions.sql new file mode 100644 index 00000000..798a492d --- /dev/null +++ b/src-tauri/crates/business/migrations/20260316000003_create_local_api_permission_definitions.sql @@ -0,0 +1,21 @@ +-- 创建本地 API 权限定义表 + +CREATE TABLE IF NOT EXISTS local_api_permission_definitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL DEFAULT (randomblob(16)) UNIQUE, + permission_code VARCHAR(128) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + description TEXT, + default_enabled INTEGER NOT NULL DEFAULT TRUE, + default_rate_limit_per_minute INTEGER NOT NULL DEFAULT 60, + default_rate_limit_per_hour INTEGER NOT NULL DEFAULT 1000, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT, + CONSTRAINT chk_local_api_permission_definitions_minute CHECK (default_rate_limit_per_minute >= 0), + CONSTRAINT chk_local_api_permission_definitions_hour CHECK (default_rate_limit_per_hour >= 0) +); + +CREATE INDEX idx_local_api_permission_definitions_sort_order + ON local_api_permission_definitions(sort_order); diff --git a/src-tauri/crates/business/migrations/20260316000004_initialize_local_api_permission_definitions.sql b/src-tauri/crates/business/migrations/20260316000004_initialize_local_api_permission_definitions.sql new file mode 100644 index 00000000..0694f031 --- /dev/null +++ b/src-tauri/crates/business/migrations/20260316000004_initialize_local_api_permission_definitions.sql @@ -0,0 +1,55 @@ +-- 初始化本地 API 权限定义 + +INSERT INTO local_api_permission_definitions ( + permission_code, name, description, default_enabled, default_rate_limit_per_minute, default_rate_limit_per_hour, sort_order +) +VALUES + ('workspaces.list', '工作空间列表', '读取工作空间列表', TRUE, 120, 3000, 10), + ('workspaces.get', '工作空间详情', '读取工作空间详情', TRUE, 120, 3000, 20), + ('workspaces.switch', '切换工作空间', '切换当前工作空间', TRUE, 60, 1000, 30), + ('browser-kernels.list', '浏览器内核列表', '读取浏览器内核列表', TRUE, 120, 3000, 40), + ('groups.list', '分组列表', '读取分组列表', TRUE, 120, 3000, 50), + ('groups.create', '创建分组', '创建新的分组', TRUE, 30, 500, 60), + ('groups.update', '更新分组', '更新分组信息', TRUE, 30, 500, 70), + ('groups.delete', '删除分组', '删除分组', TRUE, 20, 300, 80), + ('tags.list', '标签列表', '读取标签列表', TRUE, 120, 3000, 90), + ('tags.create', '创建标签', '创建新的标签', TRUE, 30, 500, 100), + ('tags.update', '更新标签', '更新标签信息', TRUE, 30, 500, 110), + ('tags.delete', '删除标签', '删除标签', TRUE, 20, 300, 120), + ('environments.list', '环境列表', '读取环境列表', TRUE, 120, 3000, 130), + ('environments.detail', '环境详情', '读取环境详情', TRUE, 120, 3000, 140), + ('environments.batch-detail', '批量环境详情', '批量读取环境详情', TRUE, 60, 1200, 150), + ('environments.create', '创建环境', '创建新的环境', TRUE, 20, 300, 160), + ('environments.batch-create', '批量创建环境', '批量创建环境', TRUE, 10, 120, 170), + ('environments.update', '更新环境', '更新环境配置', TRUE, 30, 500, 180), + ('environments.delete', '删除环境', '删除环境', TRUE, 20, 300, 190), + ('environments.batch-delete', '批量删除环境', '批量删除环境', TRUE, 10, 120, 200), + ('environments.set-proxy', '设置环境代理', '为环境设置代理', TRUE, 30, 500, 210), + ('environments.assign-tags', '分配标签', '为环境分配标签', TRUE, 30, 500, 220), + ('environments.remove-tag', '移除标签', '从环境移除标签', TRUE, 30, 500, 230), + ('environments.move-to-group', '移动到分组', '将环境移动到指定分组', TRUE, 30, 500, 240), + ('environments.batch-move-to-group', '批量移动到分组', '批量移动环境到指定分组', TRUE, 15, 180, 250), + ('environments.set-accounts', '设置环境账号', '为环境关联账号', TRUE, 20, 300, 260), + ('environments.batch-assign-tags', '批量分配标签', '批量为环境分配标签', TRUE, 15, 180, 270), + ('environments.batch-remove-tags', '批量移除标签', '批量从环境移除标签', TRUE, 15, 180, 280), + ('environments.urls.list', '环境 URL 列表', '读取环境 URL 列表', TRUE, 120, 3000, 290), + ('environments.urls.add', '添加环境 URL', '向环境添加 URL', TRUE, 30, 500, 300), + ('environments.urls.delete', '删除环境 URL', '删除环境 URL', TRUE, 30, 500, 310), + ('environments.urls.clear', '清空环境 URL', '清空环境 URL', TRUE, 20, 300, 320), + ('environments.cookies.list', '环境 Cookie 列表', '读取环境 Cookie 列表', TRUE, 120, 3000, 330), + ('environments.cookies.add', '添加环境 Cookie', '向环境添加 Cookie', TRUE, 30, 500, 340), + ('environments.cookies.delete', '删除环境 Cookie', '删除环境 Cookie', TRUE, 30, 500, 350), + ('environments.cookies.clear', '清空环境 Cookie', '清空环境 Cookie', TRUE, 20, 300, 360), + ('environments.recycle-bin.list', '回收站环境列表', '读取回收站中的环境', TRUE, 120, 3000, 370), + ('environments.recycle-bin.restore', '恢复环境', '从回收站恢复环境', TRUE, 20, 300, 380), + ('environments.recycle-bin.batch-restore', '批量恢复环境', '批量从回收站恢复环境', TRUE, 10, 120, 390), + ('environments.recycle-bin.permanent-delete', '永久删除环境', '永久删除环境', TRUE, 10, 120, 400), + ('environments.recycle-bin.batch-permanent-delete', '批量永久删除环境', '批量永久删除环境', TRUE, 5, 60, 410), + ('proxies.list', '代理列表', '读取代理列表', TRUE, 120, 3000, 420), + ('proxies.detail', '代理详情', '读取代理详情', TRUE, 120, 3000, 430), + ('proxies.create', '创建代理', '创建新的代理', TRUE, 30, 500, 440), + ('proxies.update', '更新代理', '更新代理信息', TRUE, 30, 500, 450), + ('proxies.delete', '删除代理', '删除代理', TRUE, 20, 300, 460), + ('proxies.batch-delete', '批量删除代理', '批量删除代理', TRUE, 10, 120, 470), + ('proxies.batch-import', '批量导入代理', '批量导入代理', TRUE, 10, 120, 480) +ON CONFLICT (permission_code) DO NOTHING; diff --git a/src-tauri/crates/business/migrations/20260316000005_replace_masked_key_with_api_key.sql b/src-tauri/crates/business/migrations/20260316000005_replace_masked_key_with_api_key.sql new file mode 100644 index 00000000..ad166123 --- /dev/null +++ b/src-tauri/crates/business/migrations/20260316000005_replace_masked_key_with_api_key.sql @@ -0,0 +1,9 @@ +-- 使用完整 api_key 替代 masked_key +-- 旧数据无法从 masked_key 还原明文,因此 api_key 先允许为空; +-- 服务端在发现历史记录缺少 api_key 时,会自动轮换生成新 key。 + +ALTER TABLE user_local_api_keys + ADD COLUMN api_key TEXT; + +ALTER TABLE user_local_api_keys + DROP COLUMN masked_key; diff --git a/src-tauri/crates/business/migrations/20260511000015_add_site_input_to_environment_cookies.sql b/src-tauri/crates/business/migrations/20260511000015_add_site_input_to_environment_cookies.sql new file mode 100644 index 00000000..abdac24a --- /dev/null +++ b/src-tauri/crates/business/migrations/20260511000015_add_site_input_to_environment_cookies.sql @@ -0,0 +1,5 @@ +ALTER TABLE environment_cookies +ADD COLUMN site_input TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_env_cookies_env_uuid_site_input +ON environment_cookies(environment_uuid, site_input); diff --git a/src-tauri/crates/business/migrations/20260812000001_create_local_user_auth.sql b/src-tauri/crates/business/migrations/20260812000001_create_local_user_auth.sql new file mode 100644 index 00000000..8af6154e --- /dev/null +++ b/src-tauri/crates/business/migrations/20260812000001_create_local_user_auth.sql @@ -0,0 +1,30 @@ +-- Local authentication is intentionally separate from the former cloud-account fields. +-- `users` remains the owner boundary for all business data, while this table contains +-- only the information needed by the local user picker. +CREATE TABLE IF NOT EXISTS local_user_auth ( + user_uuid TEXT PRIMARY KEY, + avatar TEXT NOT NULL, + password_salt TEXT, + password_hash TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_local_user_auth_user + FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE, + CONSTRAINT chk_local_user_auth_password + CHECK ( + (password_salt IS NULL AND password_hash IS NULL) + OR (password_salt IS NOT NULL AND password_hash IS NOT NULL) + ) +); + +-- Preserve users created by builds that predate the local user picker. Their legacy +-- account password is deliberately not imported because it used a different scheme. +INSERT INTO local_user_auth (user_uuid, avatar) +SELECT u.uuid, COALESCE(NULLIF(ui.avatar_hash, ''), '🙂') +FROM users u +JOIN user_infos ui ON ui.user_uuid = u.uuid +WHERE u.deleted_at IS NULL AND ui.deleted_at IS NULL +ON CONFLICT (user_uuid) DO NOTHING; + +CREATE INDEX IF NOT EXISTS idx_local_user_auth_created_at + ON local_user_auth(created_at); diff --git a/src-tauri/crates/business/migrations/20260812000002_create_browser_kernel_registry.sql b/src-tauri/crates/business/migrations/20260812000002_create_browser_kernel_registry.sql new file mode 100644 index 00000000..2fccd99b --- /dev/null +++ b/src-tauri/crates/business/migrations/20260812000002_create_browser_kernel_registry.sql @@ -0,0 +1,69 @@ +CREATE TABLE IF NOT EXISTS browser_kernel_artifacts ( + kernel_id TEXT PRIMARY KEY, + type_code TEXT NOT NULL, + resource_name TEXT NOT NULL, + version TEXT NOT NULL, + name TEXT, + notes TEXT, + platform TEXT NOT NULL, + package_hash TEXT NOT NULL, + executable_signature TEXT NOT NULL, + file_size INTEGER, + is_latest INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + arch TEXT NOT NULL, + package_format TEXT NOT NULL, + requires_extract INTEGER NOT NULL DEFAULT 0, + entrypoint_template TEXT, + extract_root TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_browser_kernel_artifacts_lookup + ON browser_kernel_artifacts(type_code, platform, status, is_latest); +CREATE INDEX IF NOT EXISTS idx_browser_kernel_artifacts_resource_name + ON browser_kernel_artifacts(resource_name); + +CREATE TABLE IF NOT EXISTS browser_kernel_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kernel_id TEXT NOT NULL, + source_id TEXT NOT NULL, + url TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 100, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_browser_kernel_sources_artifact + FOREIGN KEY (kernel_id) REFERENCES browser_kernel_artifacts(kernel_id) ON DELETE CASCADE, + CONSTRAINT uq_browser_kernel_sources UNIQUE (kernel_id, source_id, url) +); + +CREATE INDEX IF NOT EXISTS idx_browser_kernel_sources_resolve + ON browser_kernel_sources(kernel_id, is_active, priority); + +CREATE TABLE IF NOT EXISTS browser_kernel_installations ( + kernel_id TEXT PRIMARY KEY, + install_path TEXT NOT NULL, + verified_signature TEXT, + status TEXT NOT NULL DEFAULT 'ready', + installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + verified_at TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_browser_kernel_installations_artifact + FOREIGN KEY (kernel_id) REFERENCES browser_kernel_artifacts(kernel_id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS environment_kernel_bindings ( + environment_uuid TEXT PRIMARY KEY, + kernel_id TEXT NOT NULL, + bound_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_environment_kernel_bindings_environment + FOREIGN KEY (environment_uuid) REFERENCES environments(uuid) ON DELETE CASCADE, + CONSTRAINT fk_environment_kernel_bindings_artifact + FOREIGN KEY (kernel_id) REFERENCES browser_kernel_artifacts(kernel_id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_environment_kernel_bindings_kernel_id + ON environment_kernel_bindings(kernel_id); diff --git a/src-tauri/crates/business/migrations/20260812000003_add_browser_kernel_compatible_signatures.sql b/src-tauri/crates/business/migrations/20260812000003_add_browser_kernel_compatible_signatures.sql new file mode 100644 index 00000000..c98c44c4 --- /dev/null +++ b/src-tauri/crates/business/migrations/20260812000003_add_browser_kernel_compatible_signatures.sql @@ -0,0 +1,9 @@ +ALTER TABLE browser_kernel_artifacts + ADD COLUMN compatible_executable_signatures TEXT NOT NULL DEFAULT '[]'; + +ALTER TABLE browser_kernel_artifacts + ADD COLUMN install_dir_name TEXT; + +UPDATE browser_kernel_artifacts +SET install_dir_name = resource_name +WHERE install_dir_name IS NULL; diff --git a/src-tauri/crates/business/resources/default-browser-kernels.json b/src-tauri/crates/business/resources/default-browser-kernels.json new file mode 100644 index 00000000..b33c66d3 --- /dev/null +++ b/src-tauri/crates/business/resources/default-browser-kernels.json @@ -0,0 +1,30 @@ +{ + "schema_version": 1, + "source_id": "simprint-builtin", + "kernels": [ + { + "type_code": "SIMPRINT_KERNEL_CHROMIUM", + "resource_name": "Chrome 144", + "install_dir_name": "Chrome 144", + "version": "144.0.7559.118.1", + "name": "simprint-browser-144.0.7559.118.zip", + "notes": "Simprint Chromium kernel", + "platform": "windows", + "url": "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev/versions/144.0.7559.118.1/simprint-browser-144.0.7559.118.zip", + "priority": 100, + "hash": "c74ce58537c93e99e4099c94667a21a8e150ac4052c383bc2a095ffdb3b0e075", + "signature": "a864f950c2e77d18ef932f2ad3dbde63039c3b467994daab83081ce6b28c4f82", + "compatible_signatures": [ + "26afa023c6637f045b6825a8e14b720a2305504b313f063bc8410bb0aef19bbf" + ], + "file_size": 184526645, + "is_latest": true, + "status": "active", + "arch": "x86_64", + "package_format": "zip", + "requires_extract": true, + "entrypoint_template": null, + "extract_root": null + } + ] +} diff --git a/src-tauri/crates/business/rustfmt.toml b/src-tauri/crates/business/rustfmt.toml new file mode 100644 index 00000000..e79c5a6f --- /dev/null +++ b/src-tauri/crates/business/rustfmt.toml @@ -0,0 +1,17 @@ +# rustfmt 配置文件 +# 注意:某些配置选项需要 nightly 版本,这里只使用稳定版本支持的选项 +edition = "2021" +max_width = 100 +tab_spaces = 4 +newline_style = "Unix" +use_small_heuristics = "Default" +hard_tabs = false +chain_width = 80 +# 以下选项需要 nightly 版本,已注释 +# wrap_comments = true +# format_code_in_doc_comments = true +# format_strings = true +# format_macro_matchers = true +# format_macro_bodies = true +# format_macro_definitions = false + diff --git a/src-tauri/crates/business/src/database.rs b/src-tauri/crates/business/src/database.rs new file mode 100644 index 00000000..e6545a6c --- /dev/null +++ b/src-tauri/crates/business/src/database.rs @@ -0,0 +1,234 @@ +use std::str::FromStr; + +use sqlx::{ + Sqlite, + migrate::Migrator, + sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, +}; + +use crate::utils::DatabaseConfig; + +/// Database engine used by the embedded business layer. +pub type Db = Sqlite; +pub type Pool = sqlx::Pool; +pub type DbPool = Pool; + +static MIGRATOR: Migrator = sqlx::migrate!("./migrations"); + +/// Build a numbered placeholder list for a variable-length SQLite `IN` clause. +pub fn placeholders(start: usize, len: usize) -> String { + (start..start + len) + .map(|index| format!("${index}")) + .collect::>() + .join(", ") +} + +pub async fn connect(config: &DatabaseConfig) -> anyhow::Result { + let options = SqliteConnectOptions::from_str(&config.url)? + .create_if_missing(true) + .foreign_keys(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal); + + let pool = SqlitePoolOptions::new() + .max_lifetime(std::time::Duration::from_secs(config.max_lifetime)) + .idle_timeout(std::time::Duration::from_secs(config.idle_timeout)) + .acquire_timeout(std::time::Duration::from_secs(config.acquire_timeout)) + .max_connections(config.max_connections) + .min_connections(config.min_connections) + .connect_with(options) + .await?; + + Ok(pool) +} + +/// Bring a newly-created or existing embedded database up to the current schema. +pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> { + MIGRATOR.run(pool).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn opens_an_embedded_sqlite_database() { + let config = DatabaseConfig { + url: "sqlite::memory:".to_string(), + max_connections: 1, + min_connections: 1, + max_lifetime: 30, + acquire_timeout: 30, + idle_timeout: 30, + }; + + let pool = connect(&config).await.expect("SQLite should open"); + sqlx::query("CREATE TABLE health_check (value TEXT NOT NULL)") + .execute(&pool) + .await + .expect("schema should be writable"); + sqlx::query("INSERT INTO health_check (value) VALUES ($1)") + .bind("ok") + .execute(&pool) + .await + .expect("data should be writable"); + + let value: String = sqlx::query_scalar("SELECT value FROM health_check") + .fetch_one(&pool) + .await + .expect("data should be readable"); + assert_eq!(value, "ok"); + assert_eq!(placeholders(3, 3), "$3, $4, $5"); + } + + #[tokio::test] + async fn applies_all_migrations_to_a_fresh_database() { + let config = DatabaseConfig { + url: "sqlite::memory:".to_string(), + max_connections: 1, + min_connections: 1, + max_lifetime: 30, + acquire_timeout: 30, + idle_timeout: 30, + }; + + let pool = connect(&config).await.expect("SQLite should open"); + migrate(&pool).await.expect("all embedded migrations should apply"); + + let table_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", + ) + .fetch_one(&pool) + .await + .expect("schema metadata should be readable"); + assert!(table_count > 20, "business tables should be created"); + } + + #[tokio::test] + async fn core_models_round_trip_on_sqlite() { + use crate::entitys::{CreateTeamRequest, CreateWorkspaceRequest}; + use crate::models::{environments, proxies, teams, workspaces}; + + let mut config = DatabaseConfig::embedded("sqlite::memory:"); + config.max_connections = 1; + config.min_connections = 1; + let context = crate::svc_ctx::SvcCtx::new(&config) + .await + .expect("business context should initialize"); + let pool = &context.db; + let user_uuid = uuid::Uuid::new_v4(); + + sqlx::query("INSERT INTO users (uuid, id) VALUES ($1, $2)") + .bind(user_uuid) + .bind("LOCAL_USER") + .execute(pool) + .await + .expect("local user should be inserted"); + sqlx::query( + "INSERT INTO user_infos (user_uuid, nickname, email, password) VALUES ($1, $2, $3, $4)", + ) + .bind(user_uuid) + .bind("Local User") + .bind("local@simprint.invalid") + .bind("") + .execute(pool) + .await + .expect("local user profile should be inserted"); + + let workspace_uuid = workspaces::insert_workspace( + pool, + user_uuid, + &CreateWorkspaceRequest { + name: "Local Workspace".to_string(), + workspace_type: Some("personal".to_string()), + }, + ) + .await + .expect("workspace should be created"); + let workspace = workspaces::fetch_workspace_by_uuid(pool, workspace_uuid) + .await + .expect("workspace query should succeed") + .expect("workspace should exist"); + assert_eq!(workspace.name, "Local Workspace"); + + let team_uuid = teams::insert_team( + pool, + user_uuid, + &CreateTeamRequest { + workspace_uuid, + name: "Local Team".to_string(), + description: None, + }, + ) + .await + .expect("team should be created"); + assert!(teams::fetch_team_by_uuid(pool, team_uuid).await.unwrap().is_some()); + + let group_uuid = + environments::insert_group(pool, workspace_uuid, team_uuid, "Default", None, user_uuid) + .await + .expect("group should be created"); + let tag_uuid = + environments::insert_tag(pool, user_uuid, Some(team_uuid), "QA", Some("blue")) + .await + .expect("tag should be created"); + assert!(environments::fetch_group_by_uuid(pool, group_uuid).await.unwrap().is_some()); + assert!(environments::fetch_tag_by_uuid(pool, tag_uuid).await.unwrap().is_some()); + + let proxy_uuid = proxies::insert_proxy( + pool, + workspace_uuid, + user_uuid, + "Local Proxy", + "127.0.0.1", + 8080, + "http", + None, + Some("secret"), + None, + None, + ) + .await + .expect("proxy should be created"); + let proxy = proxies::fetch_proxy_by_uuid(pool, proxy_uuid) + .await + .expect("proxy query should succeed") + .expect("proxy should exist"); + assert_eq!(proxy.password.as_deref(), Some("secret")); + + let environment_uuid = environments::insert_environment( + pool, + workspace_uuid, + user_uuid, + team_uuid, + "Browser A", + None, + Some(group_uuid), + Some(proxy_uuid), + Some("Windows"), + Some("Chromium"), + ) + .await + .expect("environment should be created"); + let environment = + environments::fetch_environment_by_uuid(pool, workspace_uuid, environment_uuid) + .await + .expect("environment query should succeed") + .expect("environment should exist"); + assert_eq!(environment.name, "Browser A"); + assert_eq!(environment.proxy_uuid, Some(proxy_uuid)); + + workspaces::update_workspace(pool, workspace_uuid, Some("Renamed Workspace")) + .await + .expect("workspace should update"); + assert_eq!( + workspaces::fetch_workspace_by_uuid(pool, workspace_uuid) + .await + .unwrap() + .unwrap() + .name, + "Renamed Workspace" + ); + } +} diff --git a/src-tauri/crates/business/src/dispatcher.rs b/src-tauri/crates/business/src/dispatcher.rs new file mode 100644 index 00000000..dd83a58b --- /dev/null +++ b/src-tauri/crates/business/src/dispatcher.rs @@ -0,0 +1,1566 @@ +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use uuid::Uuid; + +use crate::{entitys::*, models, services, svc_ctx::SvcCtx}; + +fn payload(data: &Value) -> Result { + serde_json::from_value(data.clone()) + .map_err(|error| format!("Invalid request payload: {error}")) +} + +fn value(data: T) -> Result { + serde_json::to_value(data).map_err(|error| format!("Failed to serialize response: {error}")) +} + +async fn current_workspace(context: &SvcCtx) -> Result { + models::user::fetch_user_current_workspace(&context.db, context.local_user_uuid) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "No local workspace is selected".to_string()) +} + +async fn current_team(context: &SvcCtx) -> Result { + services::teams::get_current_team_service(context, context.local_user_uuid) + .await? + .ok_or_else(|| "No local team is selected".to_string()) +} + +/// Dispatch a migrated business POST route locally. +/// +/// `None` means the route does not belong to the embedded business service. +pub async fn dispatch_post( + context: &SvcCtx, + route: &str, + data: &Value, +) -> Option> { + let result = match route.trim_start_matches('/') { + "browser-kernels/list" => { + async { + let platform = data.get("platform").and_then(Value::as_str).unwrap_or("windows"); + let type_code = data + .get("type_code") + .and_then(Value::as_str) + .unwrap_or("SIMPRINT_KERNEL_CHROMIUM"); + value( + services::browser_kernels::list_browser_kernels( + &context.db, + Some(platform), + Some(type_code), + ) + .await?, + ) + } + .await + } + "local-api/get" => { + async { + value( + services::local_api::get_local_api_config_service( + context, + context.local_user_uuid, + ) + .await?, + ) + } + .await + } + "local-api/update" => { + async { + let request: UpdateLocalApiConfigRequest = payload(data)?; + value( + services::local_api::update_local_api_config_service( + context, + context.local_user_uuid, + &request, + ) + .await?, + ) + } + .await + } + "local-api/reset-api-key" => { + async { + value( + services::local_api::reset_local_api_key_service( + context, + context.local_user_uuid, + ) + .await?, + ) + } + .await + } + "workspaces/list" => { + let workspaces = + services::workspaces::get_user_workspaces_service(context, context.local_user_uuid) + .await; + match workspaces { + Ok(workspaces) => { + let current = current_workspace(context).await.ok(); + value(WorkspaceListResponse { + current_workspace_uuid: current, + workspaces: workspaces + .into_iter() + .map(|workspace| WorkspaceItem { + uuid: workspace.uuid, + name: workspace.name, + workspace_type: workspace.workspace_type, + is_current: current == Some(workspace.uuid), + }) + .collect(), + }) + } + Err(error) => Err(error), + } + } + "workspaces/get" => { + async { + let request: UuidRequest = payload(data)?; + value(services::workspaces::get_workspace_service(context, request.uuid).await?) + } + .await + } + "workspaces/create" => { + async { + let request: CreateWorkspaceRequest = payload(data)?; + let uuid = services::workspaces::create_workspace_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "workspaces/update" => { + async { + let request: UpdateWorkspaceRequest = payload(data)?; + services::workspaces::update_workspace_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "workspaces/delete" => { + async { + let request: UuidRequest = payload(data)?; + services::workspaces::delete_workspace_service( + context, + context.local_user_uuid, + request.uuid, + ) + .await?; + Ok(Value::Null) + } + .await + } + "workspaces/switch" => { + async { + let request: SwitchWorkspaceRequest = payload(data)?; + services::workspaces::switch_workspace_service( + context, + request.workspace_uuid, + context.local_user_uuid, + ) + .await?; + Ok(Value::Null) + } + .await + } + + "teams/my-teams" => { + async { + let teams = + services::teams::get_user_teams_service(context, context.local_user_uuid) + .await?; + let current = + services::teams::get_current_team_service(context, context.local_user_uuid) + .await?; + let mut items = Vec::with_capacity(teams.len()); + for team in teams { + let role = if team.owner_uuid == context.local_user_uuid { + "owner".to_string() + } else { + models::teams::fetch_team_member( + &context.db, + team.workspace_uuid, + team.uuid, + context.local_user_uuid, + ) + .await + .ok() + .flatten() + .map(|member| member.role) + .unwrap_or_else(|| "member".to_string()) + }; + let members_count = models::teams::fetch_team_member_count( + &context.db, + team.uuid, + None, + None, + None, + ) + .await + .unwrap_or(0); + items.push(TeamItem { + uuid: team.uuid, + name: team.name, + description: team.description, + role, + members_count, + is_current: current == Some(team.uuid), + }); + } + value(TeamListResponse { + current_team_uuid: current, + teams: items, + }) + } + .await + } + "teams/create" => { + async { + let request: CreateTeamRequest = payload(data)?; + let uuid = services::teams::create_team_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "teams/get" | "teams/detail" => { + async { + let request: UuidRequest = payload(data)?; + value(services::teams::get_team_service(context, request.uuid).await?) + } + .await + } + "teams/switch" => { + async { + let request: SwitchTeamRequest = payload(data)?; + services::teams::switch_team_service(context, context.local_user_uuid, &request) + .await?; + Ok(Value::Null) + } + .await + } + "teams/update" => { + async { + let request: UpdateTeamRequest = payload(data)?; + services::teams::update_team_service( + context, + current_workspace(context).await?, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "teams/members" => { + async { + let request: ListTeamMembersRequest = payload(data)?; + let (items, total) = services::teams::get_team_members_service( + context, + current_team(context).await?, + &request, + ) + .await?; + value(MemberListResponse { + items, + total, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + "teams/member/add" => { + async { + let request: AddMemberRequest = payload(data)?; + let member_uuid = services::teams::add_member_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + &request, + ) + .await?; + Ok(json!({ "member_uuid": member_uuid })) + } + .await + } + "teams/member/role" => { + async { + let request: UpdateMemberRoleRequest = payload(data)?; + value( + services::teams::update_member_role_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + &request, + ) + .await?, + ) + } + .await + } + "teams/member/remove" => { + async { + let request: RemoveMemberRequest = payload(data)?; + services::teams::remove_member_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + request.member_uuid, + ) + .await?; + Ok(Value::Null) + } + .await + } + "teams/leave" => { + async { + services::teams::leave_team_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + ) + .await?; + Ok(Value::Null) + } + .await + } + + "groups/list" => { + async { + value( + services::groups::get_groups_service( + context, + current_workspace(context).await?, + current_team(context).await?, + 1, + 10_000, + ) + .await?, + ) + } + .await + } + "groups/create" => { + async { + let request: CreateGroupRequest = payload(data)?; + let uuid = services::groups::create_group_service( + context, + context.local_user_uuid, + current_workspace(context).await?, + current_team(context).await?, + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "groups/update" => { + async { + let request: UpdateGroupRequest = payload(data)?; + services::groups::update_group_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "groups/delete" => { + async { + let request: UuidRequest = payload(data)?; + services::groups::delete_group_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + request.uuid, + ) + .await?; + Ok(Value::Null) + } + .await + } + "groups/batch-delete" => { + async { + let request: BatchUuidRequest = payload(data)?; + for uuid in request.uuids { + services::groups::delete_group_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + uuid, + ) + .await?; + } + Ok(Value::Null) + } + .await + } + + "group-permissions/grant" => { + async { + let request: GrantGroupPermissionRequest = payload(data)?; + services::group_permissions::grant_group_permission_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "group-permissions/revoke" => { + async { + let request: RevokeGroupPermissionRequest = payload(data)?; + services::group_permissions::revoke_group_permission_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "group-permissions/check" => { + async { + let request: CheckGroupPermissionRequest = payload(data)?; + let has_permission = services::group_permissions::check_group_permission_service( + context, + current_workspace(context).await?, + &request, + ) + .await?; + value(CheckPermissionResponse { + has_permission, + permission_type: has_permission.then_some(request.permission_type), + }) + } + .await + } + "group-permissions/list" => { + async { + let request: ListUserGroupPermissionsRequest = payload(data)?; + let items = services::group_permissions::list_user_group_permissions_service( + context, &request, + ) + .await?; + value(GroupPermissionListResponse { + total: items.len() as i64, + items, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + + "tags/list" => { + async { + value( + services::tags::get_tags_service( + context, + context.local_user_uuid, + services::teams::get_current_team_service(context, context.local_user_uuid) + .await?, + ) + .await?, + ) + } + .await + } + "tags/create" => { + async { + let request: CreateTagRequest = payload(data)?; + let uuid = services::tags::create_tag_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "tags/update" => { + async { + let request: UpdateTagRequest = payload(data)?; + services::tags::update_tag_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "tags/delete" => { + async { + let request: UuidRequest = payload(data)?; + services::tags::delete_tag_service(context, request.uuid).await?; + Ok(Value::Null) + } + .await + } + + "proxies/list" => { + async { + let request: ListProxiesRequest = payload(data)?; + let (items, total) = services::proxies::get_proxies_service( + context, + context.local_user_uuid, + current_workspace(context).await?, + Some(current_team(context).await?), + &request, + ) + .await?; + value(ProxyListResponse { + items, + total, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + "proxies/detail" => { + async { + let request: UuidRequest = payload(data)?; + value(services::proxies::get_proxy_service(context, request.uuid).await?) + } + .await + } + "proxies/create" => { + async { + let request: CreateProxyRequest = payload(data)?; + let uuid = services::proxies::create_proxy_service( + context, + context.local_user_uuid, + current_workspace(context).await?, + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "proxies/update" => { + async { + let request: UpdateProxyRequest = payload(data)?; + services::proxies::update_proxy_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "proxies/delete" => { + async { + let request: UuidRequest = payload(data)?; + services::proxies::delete_proxy_service(context, request.uuid).await?; + Ok(Value::Null) + } + .await + } + "proxies/batch-delete" => { + async { + let request: BatchUuidRequest = payload(data)?; + value( + services::proxies::batch_delete_proxies_service(context, &request.uuids) + .await?, + ) + } + .await + } + "proxies/batch-import" => { + async { + let request: BatchImportProxiesRequest = payload(data)?; + value( + services::proxies::batch_import_proxies_service( + context, + context.local_user_uuid, + current_workspace(context).await?, + &request, + ) + .await?, + ) + } + .await + } + + "proxy-visibility/set" => { + async { + let request: SetProxyVisibleRequest = payload(data)?; + services::proxy_visibility::set_proxy_visible_to_team_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "proxy-visibility/remove" => { + async { + let request: RemoveProxyVisibleRequest = payload(data)?; + services::proxy_visibility::remove_proxy_visible_from_team_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "proxy-visibility/batch-set" => { + async { + let request: BatchSetProxyVisibleRequest = payload(data)?; + services::proxy_visibility::batch_set_proxy_visible_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "proxy-visibility/list-visible" => { + async { + let mut request: ListVisibleProxiesRequest = payload(data)?; + if request.workspace_uuid.is_nil() { + request.workspace_uuid = current_workspace(context).await?; + } + value(VisibleProxyListResponse { + items: services::proxy_visibility::get_visible_proxies_service( + context, + context.local_user_uuid, + &request, + ) + .await?, + }) + } + .await + } + "proxy-visibility/list-teams" => { + async { + let request: ListProxyVisibleTeamsRequest = payload(data)?; + value( + services::proxy_visibility::get_proxy_visible_teams_service( + context, + request.proxy_uuid, + ) + .await?, + ) + } + .await + } + + "accounts/list" => { + async { + let request: ListAccountsRequest = payload(data)?; + let (items, total) = services::accounts::get_accounts_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + value(AccountListResponse { + items, + total, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + "accounts/detail" => { + async { + let request: UuidRequest = payload(data)?; + value(services::accounts::get_account_service(context, request.uuid).await?) + } + .await + } + "accounts/create" => { + async { + let request: CreateAccountRequest = payload(data)?; + let uuid = services::accounts::create_account_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "accounts/update" => { + async { + let request: UpdateAccountRequest = payload(data)?; + services::accounts::update_account_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "accounts/delete" => { + async { + let request: UuidRequest = payload(data)?; + services::accounts::delete_account_service(context, request.uuid).await?; + Ok(Value::Null) + } + .await + } + "accounts/batch-delete" => { + async { + let request: BatchUuidRequest = payload(data)?; + value( + services::accounts::batch_delete_accounts_service(context, &request.uuids) + .await?, + ) + } + .await + } + "accounts/batch-import" => { + async { + let request: BatchImportAccountsRequest = payload(data)?; + value( + services::accounts::batch_import_accounts_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?, + ) + } + .await + } + + "environments/list" => { + async { + let request: ListEnvironmentsRequest = payload(data)?; + let (items, total) = services::environments::get_environments_service( + context, + context.local_user_uuid, + current_workspace(context).await?, + current_team(context).await?, + &request, + ) + .await?; + value(EnvironmentListResponse { + items, + total, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + "environments/detail" => { + async { + let request: UuidRequest = payload(data)?; + value( + services::environments::get_environment_detail_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + request.uuid, + ) + .await?, + ) + } + .await + } + "environments/batch-detail" => { + async { + let request: BatchUuidRequest = payload(data)?; + let workspace_uuid = current_workspace(context).await?; + let team_uuid = current_team(context).await?; + let mut items = std::collections::HashMap::new(); + for uuid in request.uuids { + if let Ok(detail) = services::environments::get_environment_detail_service( + context, + workspace_uuid, + team_uuid, + context.local_user_uuid, + uuid, + ) + .await + { + items.insert(uuid.to_string(), detail); + } + } + value(items) + } + .await + } + "environments/create" => { + async { + let request: CreateEnvironmentRequest = payload(data)?; + let uuid = services::environments::create_environment_service( + context, + context.local_user_uuid, + current_workspace(context).await?, + current_team(context).await?, + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "environments/batch-create" => { + async { + let request: BatchCreateEnvironmentRequest = payload(data)?; + value( + services::environments::batch_create_environments_service( + context, + context.local_user_uuid, + current_workspace(context).await?, + current_team(context).await?, + &request, + ) + .await?, + ) + } + .await + } + "environments/update" => { + async { + let request: UpdateEnvironmentRequest = payload(data)?; + services::environments::update_environment_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "environments/delete" => { + async { + let request: UuidRequest = payload(data)?; + services::environments::delete_environment_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + request.uuid, + ) + .await?; + Ok(Value::Null) + } + .await + } + "environments/batch-delete" => { + async { + let request: BatchUuidRequest = payload(data)?; + value( + services::environments::batch_delete_environments_service( + context, + &request.uuids, + ) + .await?, + ) + } + .await + } + "environments/set-proxy" => { + async { + let request: SetEnvironmentProxyRequest = payload(data)?; + services::environments::set_environment_proxy_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "environments/set-accounts" => { + async { + let request: SetEnvironmentAccountsRequest = payload(data)?; + services::accounts::set_environment_accounts_service( + context, + request.uuid, + &request.account_uuids, + ) + .await?; + Ok(Value::Null) + } + .await + } + "environments/assign-tags" => { + async { + let request: AssignTagsRequest = payload(data)?; + services::environments::assign_tags_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "environments/remove-tag" => { + async { + let request: RemoveTagRequest = payload(data)?; + services::environments::remove_tag_service(context, request.uuid, request.tag_uuid) + .await?; + Ok(Value::Null) + } + .await + } + "environments/batch-assign-tags" => { + async { + let request: BatchAssignTagRequest = payload(data)?; + services::environments::batch_assign_tags_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "environments/batch-remove-tags" => { + async { + let request: BatchRemoveTagsRequest = payload(data)?; + services::environments::batch_remove_tags_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "environments/move-to-group" => { + async { + let request: MoveToGroupRequest = payload(data)?; + services::environments::move_to_group_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "environments/batch-move-to-group" => { + async { + let request: BatchMoveToGroupRequest = payload(data)?; + services::environments::batch_move_to_group_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "environments/recycle-bin/list" => { + async { + let request: ListEnvironmentsRequest = payload(data)?; + let (items, total) = services::environments::get_recycle_bin_environments_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + &request, + ) + .await?; + value(EnvironmentListResponse { + items, + total, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + "environments/recycle-bin/restore" => { + async { + let request: UuidRequest = payload(data)?; + services::environments::restore_environment_service(context, request.uuid).await?; + Ok(Value::Null) + } + .await + } + "environments/recycle-bin/batch-restore" => { + async { + let request: BatchUuidRequest = payload(data)?; + services::environments::batch_restore_environments_service(context, &request.uuids) + .await?; + Ok(Value::Null) + } + .await + } + "environments/recycle-bin/permanent-delete" => { + async { + let request: UuidRequest = payload(data)?; + services::environments::permanent_delete_environment_service( + context, + request.uuid, + current_workspace(context).await?, + ) + .await?; + Ok(Value::Null) + } + .await + } + "environments/recycle-bin/batch-permanent-delete" => { + async { + let request: BatchUuidRequest = payload(data)?; + services::environments::batch_permanent_delete_environments_service( + context, + &request.uuids, + current_workspace(context).await?, + ) + .await?; + Ok(Value::Null) + } + .await + } + "environments/urls/list" => { + async { + let request: UuidRequest = payload(data)?; + value( + services::environments::get_environment_urls_service(context, request.uuid) + .await?, + ) + } + .await + } + "environments/urls/add" => { + async { + let request: AddEnvironmentUrlRequest = payload(data)?; + let id = + services::environments::add_environment_url_service(context, &request).await?; + Ok(json!({ "id": id })) + } + .await + } + "environments/urls/delete" => { + async { + let request: DeleteEnvironmentUrlRequest = payload(data)?; + services::environments::delete_environment_url_service(context, request.id).await?; + Ok(Value::Null) + } + .await + } + "environments/urls/clear" => { + async { + let request: ClearEnvironmentUrlsRequest = payload(data)?; + value( + services::environments::clear_environment_urls_service( + context, + request.environment_uuid, + ) + .await?, + ) + } + .await + } + "environments/cookies/list" => { + async { + let request: UuidRequest = payload(data)?; + value( + services::environments::get_environment_cookies_service(context, request.uuid) + .await?, + ) + } + .await + } + "environments/cookies/add" => { + async { + let request: AddEnvironmentCookieRequest = payload(data)?; + let id = services::environments::add_environment_cookie_service(context, &request) + .await?; + Ok(json!({ "id": id })) + } + .await + } + "environments/cookies/delete" => { + async { + let request: DeleteEnvironmentCookieRequest = payload(data)?; + services::environments::delete_environment_cookie_service(context, request.id) + .await?; + Ok(Value::Null) + } + .await + } + "environments/cookies/clear" => { + async { + let request: ClearEnvironmentCookiesRequest = payload(data)?; + value( + services::environments::clear_environment_cookies_service( + context, + request.environment_uuid, + ) + .await?, + ) + } + .await + } + + "templates/list" => { + async { + let request: ListTemplatesRequest = payload(data)?; + let (items, total) = services::templates::get_templates_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + request.is_public, + request.pagination.page, + request.pagination.page_size, + ) + .await?; + value(TemplateListResponse { + items, + total, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + "templates/detail" => { + async { + let request: GetTemplateRequest = payload(data)?; + value( + services::templates::get_template_service( + context, + request.uuid, + request.for_create.unwrap_or(false), + ) + .await?, + ) + } + .await + } + "templates/create" => { + async { + let request: CreateTemplateRequest = payload(data)?; + let uuid = services::templates::create_template_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "templates/update" => { + async { + let request: UpdateTemplateRequest = payload(data)?; + services::templates::update_template_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "templates/delete" => { + async { + let request: UuidRequest = payload(data)?; + services::templates::delete_template_service(context, request.uuid).await?; + Ok(Value::Null) + } + .await + } + "templates/apply" => { + async { + let request: ApplyTemplateRequest = payload(data)?; + services::templates::apply_template_service( + context, + current_workspace(context).await?, + current_team(context).await?, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "templates/create-from" => { + async { + let request: CreateFromTemplateRequest = payload(data)?; + let uuid = services::templates::create_from_template_service( + context, + context.local_user_uuid, + current_workspace(context).await?, + current_team(context).await?, + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + + "rpa/tasks" => { + async { + let request: ListRpaTasksRequest = payload(data)?; + let (items, total) = services::rpa::get_rpa_tasks_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + value(RpaTaskListResponse { + items, + total, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + "rpa/tasks/detail" => { + async { + let request: UuidRequest = payload(data)?; + let (task, steps, environment_uuids) = + services::rpa::get_rpa_task_service(context, request.uuid).await?; + value(RpaTaskDetailResponse { + task, + steps, + environment_uuids, + }) + } + .await + } + "rpa/tasks/create" => { + async { + let request: CreateRpaTaskRequest = payload(data)?; + let uuid = services::rpa::create_rpa_task_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + "rpa/tasks/update" => { + async { + let request: UpdateRpaTaskRequest = payload(data)?; + services::rpa::update_rpa_task_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + "rpa/tasks/delete" => { + async { + let request: UuidRequest = payload(data)?; + services::rpa::delete_rpa_task_service(context, request.uuid).await?; + Ok(Value::Null) + } + .await + } + "rpa/tasks/batch-delete" => { + async { + let request: BatchUuidRequest = payload(data)?; + services::rpa::batch_delete_rpa_tasks_service(context, &request.uuids).await?; + Ok(Value::Null) + } + .await + } + "rpa/tasks/duplicate" => { + async { + let request: DuplicateRpaTaskRequest = payload(data)?; + let uuid = services::rpa::duplicate_rpa_task_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + Ok(json!({ "uuid": uuid })) + } + .await + } + + "messages/list" => { + async { + let request: ListMessagesRequest = payload(data)?; + value( + services::messages::get_user_messages_service( + context, + context.local_user_uuid, + &request, + ) + .await?, + ) + } + .await + } + "messages/read" => { + async { + let request: MarkMessageReadRequest = payload(data)?; + services::messages::mark_message_read_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "messages/batch-read" => { + async { + let request: BatchMarkReadRequest = payload(data)?; + services::messages::batch_mark_messages_read_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "messages/handle" => { + async { + let request: HandleMessageRequest = payload(data)?; + services::messages::handle_message_service( + context, + context.local_user_uuid, + &request, + ) + .await?; + Ok(Value::Null) + } + .await + } + "messages/stats" => { + async { + value( + services::messages::get_user_message_stats_service( + context, + context.local_user_uuid, + ) + .await?, + ) + } + .await + } + + "audit/logs" => { + async { + let request: ListAuditLogsRequest = payload(data)?; + let (items, total) = services::audit::get_audit_logs_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + value(AuditLogsListResponse { + items, + total, + page: request.pagination.page, + page_size: request.pagination.page_size, + }) + } + .await + } + "audit/logs/detail" => { + async { + let request: UuidRequest = payload(data)?; + value(services::audit::get_audit_log_service(context, request.uuid).await?) + } + .await + } + "audit/stats" => { + async { + value( + services::audit::get_audit_stats_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + ) + .await?, + ) + } + .await + } + "audit/logs/export" => { + async { + let request: ExportAuditLogsRequest = payload(data)?; + let (content, filename, mime_type) = services::audit::export_audit_logs_service( + context, + context.local_user_uuid, + Some(current_team(context).await?), + &request, + ) + .await?; + value(ExportResponse { + content, + filename, + mime_type, + }) + } + .await + } + + "workspace-quotas/get" => { + async { + let mut request: GetWorkspaceQuotaRequest = payload(data)?; + if request.workspace_uuid.is_none() { + request.workspace_uuid = Some(current_workspace(context).await?); + } + value( + services::workspace_quotas::get_workspace_quota_service(context, &request) + .await?, + ) + } + .await + } + "workspace-quotas/update" => { + async { + let request: UpdateQuotaUsageRequest = payload(data)?; + services::workspace_quotas::update_quota_usage_service(context, &request).await?; + Ok(Value::Null) + } + .await + } + + "preferences/get" => { + async { + value( + services::preferences::get_preferences_service( + context, + context.local_user_uuid, + ) + .await?, + ) + } + .await + } + "preferences/update" => { + async { + let request: UpdatePreferencesRequest = payload(data)?; + value( + services::preferences::update_preferences_service( + context, + context.local_user_uuid, + &request, + ) + .await?, + ) + } + .await + } + _ => return None, + }; + + Some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::DatabaseConfig; + + #[tokio::test] + async fn migrated_routes_use_the_embedded_database() { + let mut config = DatabaseConfig::embedded("sqlite::memory:"); + config.max_connections = 1; + config.min_connections = 1; + let context = SvcCtx::new(&config).await.expect("context should initialize"); + + let workspace_list = dispatch_post(&context, "workspaces/list", &json!({})) + .await + .expect("route should be local") + .expect("workspace list should succeed"); + assert_eq!(workspace_list["workspaces"].as_array().unwrap().len(), 1); + + let kernels = dispatch_post( + &context, + "browser-kernels/list", + &json!({ + "platform": "windows", + "type_code": "SIMPRINT_KERNEL_CHROMIUM" + }), + ) + .await + .unwrap() + .expect("browser kernels should be queried from the local registry"); + assert_eq!( + kernels["SIMPRINT_KERNEL_CHROMIUM"][0]["kernel_id"].as_str().map(str::len), + Some(64) + ); + + let group = dispatch_post( + &context, + "groups/create", + &json!({ "name": "Local Group", "description": null }), + ) + .await + .unwrap() + .expect("group creation should succeed"); + assert!(group["uuid"].as_str().is_some()); + + dispatch_post( + &context, + "proxies/create", + &json!({ + "name": "Local Proxy", + "host": "127.0.0.1", + "port": 8080, + "proxy_type": "http", + "password": "secret" + }), + ) + .await + .unwrap() + .expect("proxy creation should succeed"); + let proxies = dispatch_post( + &context, + "proxies/list", + &json!({ "page": 1, "page_size": 20 }), + ) + .await + .unwrap() + .expect("proxy list should succeed"); + assert_eq!(proxies["total"], 1); + + let local_api = dispatch_post(&context, "local-api/get", &json!({})) + .await + .unwrap() + .expect("local API config should be stored locally"); + let api_key = local_api["apiKey"].as_str().unwrap().to_string(); + assert!(api_key.starts_with("sk_local_")); + dispatch_post( + &context, + "local-api/update", + &json!({ "enabled": true, "port": 18080, "remoteAccess": false }), + ) + .await + .unwrap() + .expect("local API config update should succeed"); + services::local_api::validate_local_api_key_service( + &context, + &ValidateLocalApiKeyRequest { + api_key, + permission_code: "workspaces.list".to_string(), + }, + ) + .await + .expect("local API key should validate without a remote cache"); + + assert!(dispatch_post(&context, "auth/login", &json!({})).await.is_none()); + } +} diff --git a/src-tauri/crates/business/src/dto/accounts.rs b/src-tauri/crates/business/src/dto/accounts.rs new file mode 100644 index 00000000..918d8836 --- /dev/null +++ b/src-tauri/crates/business/src/dto/accounts.rs @@ -0,0 +1,35 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +/// 平台账号 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct PlatformAccountDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub password: Option, + pub status: String, + pub remark: Option, + pub usage_count: Option, + pub environments_count: Option, + pub last_used_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 环境账号关联 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct EnvironmentAccountDto { + pub id: i32, + pub environment_uuid: Uuid, + pub account_uuid: Uuid, + pub sort_order: Option, + pub created_at: DateTime, +} diff --git a/src-tauri/crates/business/src/dto/audit.rs b/src-tauri/crates/business/src/dto/audit.rs new file mode 100644 index 00000000..3ec1b06f --- /dev/null +++ b/src-tauri/crates/business/src/dto/audit.rs @@ -0,0 +1,26 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 审计日志 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AuditLogDto { + pub id: i64, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub action: String, + pub target_type: String, + pub target_uuid: Option, + pub target_name: Option, + pub details: Option, + pub changes: Option, + pub ip_address: Option, + pub user_agent: Option, + pub request_id: Option, + pub created_at: DateTime, + // 用户信息(联表查询) + pub user_name: Option, + pub user_email: Option, +} diff --git a/src-tauri/crates/business/src/dto/environments.rs b/src-tauri/crates/business/src/dto/environments.rs new file mode 100644 index 00000000..57754939 --- /dev/null +++ b/src-tauri/crates/business/src/dto/environments.rs @@ -0,0 +1,307 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +/// 分组 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct GroupDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub team_name: Option, + pub name: String, + pub description: Option, + pub sort_order: Option, + pub created_by: Option, + pub created_by_name: Option, + pub environments_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 标签 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct TagDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub name: String, + pub color: Option, + pub sort_order: Option, + pub environments_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 环境 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct EnvironmentDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Uuid, + pub name: String, + pub description: Option, + pub status: String, + pub group_uuid: Option, + pub proxy_uuid: Option, + pub system_info: Option, + pub kernel_info: Option, + pub fingerprint_summary: Option, + pub last_opened_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 环境列表行(基础查询结果) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct EnvironmentRowDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Uuid, + pub name: String, + pub description: Option, + pub status: String, + pub system_info: Option, + pub kernel_info: Option, + pub fingerprint_summary: Option, + pub group_uuid: Option, + pub proxy_uuid: Option, + pub last_opened_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 环境标签关联行(用于批量查询) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct EnvironmentTagRowDto { + pub environment_uuid: Uuid, + pub tag_id: i32, + pub tag_uuid: Uuid, + pub tag_name: String, + pub tag_color: Option, + pub tag_sort_order: Option, + pub tag_user_uuid: Uuid, + pub tag_team_uuid: Option, + pub tag_environments_count: Option, + pub tag_created_at: chrono::DateTime, + pub tag_updated_at: chrono::DateTime, + pub tag_deleted_at: Option>, +} + +/// 环境账号关联行(用于批量查询) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct EnvironmentAccountRowDto { + pub environment_uuid: Uuid, + pub account_id: i32, + pub account_uuid: Uuid, + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub account_status: String, + pub remark: Option, +} + +/// 分组查询行(用于批量查询) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct GroupRowDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub sort_order: Option, +} + +/// 代理查询行(用于批量查询,排除敏感数据) +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct ProxyRowDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub country: Option, + pub city: Option, + pub status: String, + pub latency: Option, + pub last_check_ip: Option, +} + +/// 代理摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxySummaryDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub country: Option, + pub city: Option, + pub status: String, + pub latency: Option, + pub last_check_ip: Option, +} + +/// 分组摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupSummaryDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub sort_order: Option, +} + +/// 标签摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize)] +pub struct TagSummaryDto { + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub color: Option, + pub sort_order: Option, +} + +/// 账号摘要 DTO(用于环境列表,排除敏感数据) +#[derive(Debug, Clone, Serialize)] +pub struct AccountSummaryDto { + pub id: i32, + pub uuid: Uuid, + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub status: String, + pub remark: Option, +} + +/// 环境列表项 DTO(包含完整关联数据) +#[derive(Debug, Clone, Serialize)] +pub struct EnvironmentListItemDto { + // 基础信息 + pub id: i32, + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub status: String, + pub system_info: Option, + pub kernel_info: Option, + pub fingerprint_summary: Option, + pub last_opened_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + // 分组详情(完整对象) + pub group: Option, + // 代理详情(完整对象) + pub proxy: Option, + // 标签列表(完整对象列表,与环境详情接口保持一致) + pub tags: Vec, + // 账号列表(完整对象列表) + pub accounts: Vec, + // 扩展列表(插件列表) + pub extensions: Vec, +} + +/// 扩展摘要 DTO(用于环境列表) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtensionSummaryDto { + pub extension_id: String, + pub name: String, + pub version: String, + pub icon_url: Option, + pub download_url: Option, + pub hash: Option, + pub scope: String, // user, team, group-personal, group-team +} + +/// 环境配置 DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct EnvironmentConfigDto { + pub id: i32, + pub environment_uuid: Uuid, + pub window_info: serde_json::Value, + pub basic_settings: serde_json::Value, + pub fingerprint_settings: serde_json::Value, + pub device_settings: serde_json::Value, + pub preference_settings: serde_json::Value, + pub project_metadata: serde_json::Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 环境标签关联 DTO +#[derive(Debug, Clone, FromRow)] +pub struct EnvironmentTagDto { + pub id: i32, + pub environment_uuid: Uuid, + pub tag_uuid: Uuid, + pub created_at: DateTime, +} + +/// 环境 URL DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct EnvironmentUrlDto { + pub id: i32, + pub environment_uuid: Uuid, + pub url: String, + pub title: Option, + pub sort_order: Option, + pub created_at: DateTime, +} + +/// 环境 Cookie DTO +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct EnvironmentCookieDto { + pub id: i32, + pub environment_uuid: Uuid, + pub site_input: String, + pub domain: String, + pub name: String, + pub value: String, + pub path: Option, + pub expires_at: Option>, + pub http_only: Option, + pub secure: Option, + pub same_site: Option, + pub created_at: DateTime, +} + +/// 环境 Cookie 分组 DTO +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnvironmentCookieGroupDto { + pub site: String, + pub cookie_text: String, +} + +/// 模板 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TemplateDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub name: String, + pub description: Option, + pub is_public: Option, + pub system_info: Option, + pub kernel_info: Option, + pub config_json: serde_json::Value, + pub usage_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/src-tauri/crates/business/src/dto/group_member_permissions.rs b/src-tauri/crates/business/src/dto/group_member_permissions.rs new file mode 100644 index 00000000..356b82d4 --- /dev/null +++ b/src-tauri/crates/business/src/dto/group_member_permissions.rs @@ -0,0 +1,32 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 分组权限 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct GroupMemberPermissionDto { + pub group_uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub user_uuid: Uuid, + pub permission_type: String, + pub granted_by: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 分组权限详情 DTO(包含用户信息) +#[derive(Debug, Clone, Serialize)] +pub struct GroupMemberPermissionDetailDto { + pub group_uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub user_uuid: Uuid, + pub permission_type: String, + pub granted_by: Uuid, + pub user_name: Option, + pub user_email: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src-tauri/crates/business/src/dto/local_api.rs b/src-tauri/crates/business/src/dto/local_api.rs new file mode 100644 index 00000000..8afeb27d --- /dev/null +++ b/src-tauri/crates/business/src/dto/local_api.rs @@ -0,0 +1,98 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, FromRow)] +pub struct LocalApiSettingsDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub enabled: bool, + pub port: i32, + pub remote_access: bool, + pub cors_origins: Value, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct LocalApiKeyDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub key_prefix: String, + pub key_hash: String, + pub api_key: Option, + pub is_active: bool, + pub requests_today: i32, + pub daily_limit: i32, + pub last_reset_date: NaiveDate, + pub last_used_at: Option>, + pub expires_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct LocalApiKeyPermissionDto { + pub id: i32, + pub uuid: Uuid, + pub api_key_id: i32, + pub permission_code: String, + pub is_enabled: bool, + pub rate_limit_per_minute: i32, + pub rate_limit_per_hour: i32, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct LocalApiPermissionDefinitionDto { + pub id: i32, + pub uuid: Uuid, + pub permission_code: String, + pub name: String, + pub description: Option, + pub default_enabled: bool, + pub default_rate_limit_per_minute: i32, + pub default_rate_limit_per_hour: i32, + pub sort_order: i32, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalApiConfigDto { + pub enabled: bool, + pub api_key: String, + pub port: i32, + pub remote_access: bool, + pub cors_origins: Vec, + pub requests_today: i32, + pub daily_limit: i32, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResetLocalApiKeyDto { + pub api_key: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidateLocalApiKeyDto { + pub valid: bool, + pub user_uuid: uuid::Uuid, + pub permission_code: String, + pub requests_today: i32, + pub daily_limit: i32, + pub rate_limit_per_minute: i32, + pub rate_limit_per_hour: i32, +} diff --git a/src-tauri/crates/business/src/dto/messages.rs b/src-tauri/crates/business/src/dto/messages.rs new file mode 100644 index 00000000..1227f26c --- /dev/null +++ b/src-tauri/crates/business/src/dto/messages.rs @@ -0,0 +1,60 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 消息 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct MessageDto { + pub id: i32, + pub uuid: Uuid, + pub message_type: String, + pub title: String, + pub content: Option, + pub sender_uuid: Option, + pub recipient_type: String, + pub related_type: Option, + pub related_uuid: Option, + pub metadata: Option, + pub status: String, + pub priority: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 用户消息关联 DTO(包含消息详情和用户状态) +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserMessageDto { + // 消息基本信息 + pub message_uuid: Uuid, + pub message_type: String, + pub title: String, + pub content: Option, + pub sender_uuid: Option, + pub related_type: Option, + pub related_uuid: Option, + pub metadata: Option, + pub priority: String, + pub message_created_at: DateTime, + + // 用户消息状态 + pub is_read: bool, + pub read_at: Option>, + pub action_status: Option, + pub action_at: Option>, + + // 发送者信息(可选,通过 JOIN 获取) + #[sqlx(default)] + pub sender_name: Option, + #[sqlx(default)] + pub sender_email: Option, +} + +/// 消息统计 DTO +#[derive(Debug, Clone, Serialize)] +pub struct MessageStatsDto { + pub total: i64, + pub unread: i64, + pub by_type: std::collections::HashMap, +} diff --git a/src-tauri/crates/business/src/dto/mod.rs b/src-tauri/crates/business/src/dto/mod.rs new file mode 100644 index 00000000..7cbd14a4 --- /dev/null +++ b/src-tauri/crates/business/src/dto/mod.rs @@ -0,0 +1,33 @@ +pub mod user; + +// 新增模块 +pub mod accounts; +pub mod audit; +pub mod environments; +pub mod group_member_permissions; +pub mod local_api; +pub mod messages; +pub mod proxies; +pub mod proxy_visible_teams; +pub mod rpa; +pub mod system; +pub mod teams; +pub mod workspace_quotas; +pub mod workspaces; + +pub use user::*; + +// 新增导出 +pub use accounts::*; +pub use audit::*; +pub use environments::*; +pub use group_member_permissions::*; +pub use local_api::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visible_teams::*; +pub use rpa::*; +pub use system::*; +pub use teams::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src-tauri/crates/business/src/dto/proxies.rs b/src-tauri/crates/business/src/dto/proxies.rs new file mode 100644 index 00000000..411f19bf --- /dev/null +++ b/src-tauri/crates/business/src/dto/proxies.rs @@ -0,0 +1,43 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 代理 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ProxyDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub owner_uuid: Uuid, + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub ssh_key_encrypted: Option, + pub ssh_passphrase_encrypted: Option, + pub country: Option, + pub city: Option, + pub status: String, + pub latency: Option, + pub last_check_ip: Option, + pub last_checked_at: Option>, + pub environments_count: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 代理健康检查 DTO +#[derive(Debug, Clone, FromRow)] +pub struct ProxyHealthCheckDto { + pub id: i64, + pub proxy_uuid: Uuid, + pub status: String, + pub latency: Option, + pub ip_address: Option, + pub error_message: Option, + pub checked_at: DateTime, +} diff --git a/src-tauri/crates/business/src/dto/proxy_visible_teams.rs b/src-tauri/crates/business/src/dto/proxy_visible_teams.rs new file mode 100644 index 00000000..92c67309 --- /dev/null +++ b/src-tauri/crates/business/src/dto/proxy_visible_teams.rs @@ -0,0 +1,23 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 代理可见团队 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ProxyVisibleTeamDto { + pub proxy_uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub created_at: DateTime, +} + +/// 代理可见团队详情 DTO(包含团队信息) +#[derive(Debug, Clone, Serialize)] +pub struct ProxyVisibleTeamDetailDto { + pub proxy_uuid: Uuid, + pub workspace_uuid: Uuid, + pub team_uuid: Uuid, + pub team_name: Option, + pub created_at: DateTime, +} diff --git a/src-tauri/crates/business/src/dto/rpa.rs b/src-tauri/crates/business/src/dto/rpa.rs new file mode 100644 index 00000000..acc72d93 --- /dev/null +++ b/src-tauri/crates/business/src/dto/rpa.rs @@ -0,0 +1,83 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// RPA 任务 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RpaTaskDto { + pub id: i32, + pub uuid: Uuid, + pub user_uuid: Uuid, + pub team_uuid: Option, + pub name: String, + pub description: Option, + pub tags: Option, + pub trigger_type: String, + pub schedule: Option, + pub cron_expression: Option, + pub run_mode: String, + pub retry_count: Option, + pub retry_interval: Option, + pub timeout: Option, + pub concurrency: Option, + pub stop_on_error: Option, + pub notify_on_complete: Option, + pub notify_on_error: Option, + pub status: String, + pub run_count: Option, + pub success_count: Option, + pub environment_count: Option, + pub last_run_at: Option>, + pub next_run_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// RPA 任务步骤 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RpaTaskStepDto { + pub id: i32, + pub uuid: Uuid, + pub task_uuid: Uuid, + pub step_type: String, + pub name: String, + pub config: serde_json::Value, + pub enabled: Option, + pub position_x: Option, + pub position_y: Option, + pub sort_order: Option, + pub next_step_uuid: Option, + pub branch_config: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// RPA 任务环境关联 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RpaTaskEnvironmentDto { + pub id: i32, + pub task_uuid: Uuid, + pub environment_uuid: Uuid, + pub sort_order: Option, + pub created_at: DateTime, +} + +/// RPA 任务执行记录 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RpaTaskRunDto { + pub id: i64, + pub uuid: Uuid, + pub task_uuid: Uuid, + pub status: String, + pub total_steps: i32, + pub completed_steps: i32, + pub failed_steps: i32, + pub started_at: DateTime, + pub finished_at: Option>, + pub duration_ms: Option, + pub result_summary: Option, + pub error_message: Option, + pub logs: Option, +} diff --git a/src-tauri/crates/business/src/dto/system.rs b/src-tauri/crates/business/src/dto/system.rs new file mode 100644 index 00000000..7a8ea75d --- /dev/null +++ b/src-tauri/crates/business/src/dto/system.rs @@ -0,0 +1,27 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 用户偏好设置 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserPreferenceDto { + pub id: i32, + pub user_uuid: Uuid, + pub theme: String, + pub language: String, + pub notifications_enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 系统配置 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct SystemConfigDto { + pub id: i32, + pub config_key: String, + pub config_value: serde_json::Value, + pub description: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src-tauri/crates/business/src/dto/teams.rs b/src-tauri/crates/business/src/dto/teams.rs new file mode 100644 index 00000000..7e0f27df --- /dev/null +++ b/src-tauri/crates/business/src/dto/teams.rs @@ -0,0 +1,84 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 团队 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TeamDto { + pub id: i32, + pub uuid: Uuid, + pub workspace_uuid: Uuid, + pub name: String, + pub description: Option, + pub owner_uuid: Uuid, + pub avatar_hash: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 团队摘要 DTO(用于登录响应等场景,只包含基本信息) +#[derive(Debug, Clone, Serialize)] +pub struct TeamSummaryDto { + pub uuid: Uuid, + pub name: String, + pub description: Option, +} + +/// 团队成员 DTO(包含用户信息) +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TeamMemberDto { + pub id: i32, + pub team_uuid: Uuid, + pub workspace_uuid: Uuid, + pub user_uuid: Uuid, + pub role: String, + pub joined_at: DateTime, + pub invited_by: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, + // 从 user_infos 表关联的用户信息 + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar: Option, +} + +/// 团队邀请 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct TeamInvitationDto { + pub id: i32, + pub uuid: Uuid, + pub team_uuid: Uuid, + pub email: String, + pub role: String, + pub invited_by: Uuid, + pub token: String, + pub expires_at: DateTime, + pub status: String, + pub accepted_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 登录历史 DTO +#[derive(Debug, Clone, FromRow)] +pub struct LoginHistoryDto { + pub id: i64, + pub user_uuid: Uuid, + pub ip_address: String, + pub device_info: Option, + pub user_agent: Option, + pub location: Option, + pub country: Option, + pub city: Option, + pub success: bool, + pub failure_reason: Option, + pub created_at: DateTime, +} diff --git a/src-tauri/crates/business/src/dto/user.rs b/src-tauri/crates/business/src/dto/user.rs new file mode 100644 index 00000000..e49baa0f --- /dev/null +++ b/src-tauri/crates/business/src/dto/user.rs @@ -0,0 +1,31 @@ +use chrono::{DateTime, Utc}; +use sqlx::FromRow; +use uuid::Uuid; + +/// 用户基础信息 DTO +#[derive(Debug, Clone, FromRow)] +pub struct UserDto { + pub uuid: Uuid, + pub id: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 用户详细信息 DTO +#[derive(Debug, Clone, FromRow)] +pub struct UserInfoDto { + pub id: i32, + pub user_uuid: Uuid, + pub nickname: Option, + pub email: String, + pub phone: Option, + pub password: String, + pub avatar_hash: Option, + pub status: String, + pub current_team_uuid: Option, + pub current_workspace_uuid: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/src-tauri/crates/business/src/dto/workspace_quotas.rs b/src-tauri/crates/business/src/dto/workspace_quotas.rs new file mode 100644 index 00000000..9aaf60ec --- /dev/null +++ b/src-tauri/crates/business/src/dto/workspace_quotas.rs @@ -0,0 +1,20 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 工作空间配额 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct WorkspaceQuotaDto { + pub workspace_uuid: Uuid, + pub max_environments: i32, + pub used_environments: i32, + pub max_team_members: i32, + pub used_team_members: i32, + pub max_proxies: i32, + pub used_proxies: i32, + pub max_rpa_tasks: i32, + pub used_rpa_tasks: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src-tauri/crates/business/src/dto/workspaces.rs b/src-tauri/crates/business/src/dto/workspaces.rs new file mode 100644 index 00000000..4058defe --- /dev/null +++ b/src-tauri/crates/business/src/dto/workspaces.rs @@ -0,0 +1,25 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +/// 工作空间 DTO +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct WorkspaceDto { + pub uuid: Uuid, + pub name: String, + pub owner_uuid: Uuid, + pub workspace_type: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// 工作空间摘要 DTO(用于列表显示等场景) +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceSummaryDto { + pub uuid: Uuid, + pub name: String, + pub workspace_type: String, + pub owner_uuid: Uuid, +} diff --git a/src-tauri/crates/business/src/entitys/accounts.rs b/src-tauri/crates/business/src/entitys/accounts.rs new file mode 100644 index 00000000..700855d5 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/accounts.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询账号列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListAccountsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 账号筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AccountFilters { + pub keyword: Option, + pub platform_name: Option, + pub status: Option, +} + +/// 创建账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateAccountRequest { + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub password: Option, + pub remark: Option, +} + +/// 更新账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateAccountRequest { + pub uuid: Uuid, + pub platform_url: Option, + pub platform_name: Option, + pub account: Option, + pub password: Option, + pub remark: Option, + pub status: Option, +} + +/// 批量导入账号项(客户端已解析好的结构化数据) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchImportAccountItem { + pub platform_url: String, + pub platform_name: Option, + pub account: String, + pub password: Option, + pub remark: Option, +} + +/// 批量导入账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchImportAccountsRequest { + pub accounts: Vec, +} + +/// 导出账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExportAccountsRequest { + pub uuids: Option>, + pub format: String, + pub include_password: bool, +} + +// ========== 响应结构体 ========== + +/// 账号列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct AccountListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} diff --git a/src-tauri/crates/business/src/entitys/audit.rs b/src-tauri/crates/business/src/entitys/audit.rs new file mode 100644 index 00000000..403c5f29 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/audit.rs @@ -0,0 +1,83 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; +use crate::dto::AuditLogDto; + +/// 查询审计日志请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListAuditLogsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +// ========== 响应结构体 ========== + +/// 审计日志列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct AuditLogsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 导出响应 +#[derive(Debug, Clone, Serialize)] +pub struct ExportResponse { + pub content: String, + pub filename: String, + pub mime_type: String, +} + +/// 审计统计响应 +#[derive(Debug, Clone, Serialize)] +pub struct AuditStatsResponse { + pub total_logs: i64, + pub logs_today: i64, + pub logs_this_week: i64, + pub logs_this_month: i64, + pub top_actions: Vec, + pub top_target_types: Vec, +} + +/// 操作计数 +#[derive(Debug, Clone, Serialize)] +pub struct ActionCount { + pub action: String, + pub count: i64, +} + +/// 目标类型计数 +#[derive(Debug, Clone, Serialize)] +pub struct TargetTypeCount { + pub target_type: String, + pub count: i64, +} + +/// 审计日志筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuditLogFilters { + pub keyword: Option, + pub action: Option, + pub target_type: Option, + pub user_uuid: Option, + pub date_from: Option, + pub date_to: Option, +} + +/// 导出审计日志请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExportAuditLogsRequest { + pub format: String, + pub filters: Option, + pub max_records: Option, +} + +/// 审计统计请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuditStatsRequest { + pub date_from: Option, + pub date_to: Option, +} diff --git a/src-tauri/crates/business/src/entitys/common.rs b/src-tauri/crates/business/src/entitys/common.rs new file mode 100644 index 00000000..b5259dc0 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/common.rs @@ -0,0 +1,65 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ========== 请求参数 ========== + +/// 分页请求参数 +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct Pagination { + #[serde(default = "default_page")] + pub page: i64, + #[serde(default = "default_page_size")] + pub page_size: i64, + #[serde(default)] + pub sort_by: Option, + #[serde(default)] + pub sort_order: Option, +} + +fn default_page() -> i64 { + 1 +} + +fn default_page_size() -> i64 { + 20 +} + +/// UUID 请求参数 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UuidRequest { + pub uuid: Uuid, +} + +/// 批量 UUID 请求参数 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchUuidRequest { + pub uuids: Vec, +} + +// ========== 响应结构体 ========== + +/// 创建资源响应(返回新创建资源的 UUID) +#[derive(Debug, Clone, Serialize)] +pub struct CreateResponse { + pub uuid: Uuid, +} + +/// 创建资源响应(返回新创建资源的数字 ID) +#[derive(Debug, Clone, Serialize)] +pub struct IdResponse { + pub id: i32, +} + +/// 邀请响应 +#[derive(Debug, Clone, Serialize)] +pub struct InviteResponse { + pub invitation_uuid: Uuid, +} + +/// 批量导入响应 +#[derive(Debug, Clone, Serialize)] +pub struct BatchImportResponse { + pub success_count: i32, + pub failed_count: i32, + pub errors: Vec, +} diff --git a/src-tauri/crates/business/src/entitys/environments.rs b/src-tauri/crates/business/src/entitys/environments.rs new file mode 100644 index 00000000..7e2bb520 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/environments.rs @@ -0,0 +1,235 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询环境列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListEnvironmentsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 环境筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct EnvironmentFilters { + pub keyword: Option, + pub status: Option, + pub group_uuid: Option, + pub tag_uuids: Option>, + pub created_from: Option, + pub created_to: Option, +} + +/// 创建环境请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateEnvironmentRequest { + pub name: String, + pub description: Option, + pub group_uuid: Option, + pub tag_uuids: Option>, + pub account_uuids: Option>, + pub proxy_uuid: Option, // 代理 UUID(单个,可选) + pub cookies: Option>, + pub urls: Option>, + pub config: EnvironmentConfigRequest, +} + +/// 环境配置请求(对应 WindowConfig) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct EnvironmentConfigRequest { + pub window_info: serde_json::Value, + pub basic_settings: serde_json::Value, + pub fingerprint_settings: serde_json::Value, + pub device_settings: serde_json::Value, + pub preference_settings: serde_json::Value, + #[serde(default)] + pub project_metadata: serde_json::Value, +} + +/// 批量创建环境请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchCreateEnvironmentRequest { + pub environments: Vec, // 环境创建请求数组 +} + +/// 更新环境请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateEnvironmentRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub group_uuid: Option, + pub cookies: Option>, + pub urls: Option>, + pub config: Option, +} + +/// 设置环境代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SetEnvironmentProxyRequest { + pub uuid: Uuid, + pub proxy_uuid: Option, +} + +/// 设置环境账号请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SetEnvironmentAccountsRequest { + pub uuid: Uuid, + pub account_uuids: Vec, +} + +/// 分配标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AssignTagsRequest { + pub uuid: Uuid, + pub tag_uuids: Vec, +} + +/// 批量分配标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchAssignTagRequest { + pub env_uuids: Vec, + pub tag_uuid: Uuid, +} + +/// 批量移除标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchRemoveTagsRequest { + pub env_uuids: Vec, + pub tag_uuid: Option, +} + +/// 移除标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RemoveTagRequest { + pub uuid: Uuid, + pub tag_uuid: Uuid, +} + +/// 移动到分组请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MoveToGroupRequest { + pub uuid: Uuid, + pub group_uuid: Option, +} + +/// 批量移动到分组请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchMoveToGroupRequest { + pub env_uuids: Vec, + pub group_uuid: Uuid, +} + +// ============ Environment URLs ============ + +/// 添加环境 URL 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AddEnvironmentUrlRequest { + pub environment_uuid: Uuid, + pub url: String, + pub title: Option, + pub sort_order: Option, +} + +/// 批量添加环境 URL 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchAddEnvironmentUrlsRequest { + pub environment_uuid: Uuid, + pub urls: Vec, +} + +/// URL 输入 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UrlInput { + pub url: String, + pub title: Option, + pub sort_order: Option, +} + +/// 删除环境 URL 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DeleteEnvironmentUrlRequest { + pub id: i32, +} + +/// 清空环境 URL 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ClearEnvironmentUrlsRequest { + pub environment_uuid: Uuid, +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AddEnvironmentCookieRequest { + pub environment_uuid: Uuid, + pub site: String, + pub cookie_text: String, +} + +/// 批量添加环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchAddEnvironmentCookiesRequest { + pub environment_uuid: Uuid, + pub cookies: Vec, +} + +/// Cookie 分组输入 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CookieGroupInput { + pub site: String, + pub cookie_text: String, +} + +/// 删除环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DeleteEnvironmentCookieRequest { + pub id: i32, +} + +/// 清空环境 Cookie 请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ClearEnvironmentCookiesRequest { + pub environment_uuid: Uuid, +} + +/// Cookie 输入结构(用于批量添加) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CookieInput { + pub site_input: String, + pub domain: String, + pub name: String, + pub value: String, + pub path: Option, + pub http_only: Option, + pub secure: Option, + pub same_site: Option, +} + +// ========== 响应结构体 ========== + +/// 环境列表响应(使用与环境详情一致的数据结构) +#[derive(Debug, Clone, Serialize)] +pub struct EnvironmentListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 环境详情响应(包含完整配置) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnvironmentDetailResponse { + pub environment: crate::dto::EnvironmentDto, + pub config: Option, + pub cookies: Vec, + pub urls: Vec, + pub tags: Vec, + pub accounts: Vec, + pub group: Option, // 分组完整信息 + pub proxy: Option, // 代理完整信息 + pub extensions: Vec, // 扩展列表 +} diff --git a/src-tauri/crates/business/src/entitys/group_member_permissions.rs b/src-tauri/crates/business/src/entitys/group_member_permissions.rs new file mode 100644 index 00000000..2954e66d --- /dev/null +++ b/src-tauri/crates/business/src/entitys/group_member_permissions.rs @@ -0,0 +1,54 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 授予分组权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GrantGroupPermissionRequest { + pub group_uuid: Uuid, + pub user_uuid: Uuid, + pub permission_type: String, // 'read', 'write', 'manage' +} + +/// 撤销分组权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RevokeGroupPermissionRequest { + pub group_uuid: Uuid, + pub user_uuid: Uuid, +} + +/// 查询用户分组权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListUserGroupPermissionsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub user_uuid: Uuid, + pub group_uuid: Option, +} + +/// 检查分组权限请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CheckGroupPermissionRequest { + pub group_uuid: Uuid, + pub user_uuid: Uuid, + pub permission_type: String, // 'read', 'write', 'manage' +} + +// ========== 响应结构体 ========== + +/// 分组权限列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct GroupPermissionListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 检查权限响应 +#[derive(Debug, Clone, Serialize)] +pub struct CheckPermissionResponse { + pub has_permission: bool, + pub permission_type: Option, +} diff --git a/src-tauri/crates/business/src/entitys/groups.rs b/src-tauri/crates/business/src/entitys/groups.rs new file mode 100644 index 00000000..5856f3f2 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/groups.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询分组列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListGroupsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub keyword: Option, +} + +/// 创建分组请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateGroupRequest { + pub name: String, + pub description: Option, +} + +/// 更新分组请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateGroupRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub sort_order: Option, +} + +/// 分配分组到团队请求(已废弃,分组创建时即指定团队) +#[derive(Debug, Clone, Deserialize, Serialize)] +#[deprecated(note = "分组创建时即指定团队,不再需要分配")] +pub struct AssignGroupToTeamRequest { + pub uuid: Uuid, + pub team_uuid: Uuid, +} diff --git a/src-tauri/crates/business/src/entitys/local_api.rs b/src-tauri/crates/business/src/entitys/local_api.rs new file mode 100644 index 00000000..c2fba04f --- /dev/null +++ b/src-tauri/crates/business/src/entitys/local_api.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct GetLocalApiConfigRequest {} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ResetLocalApiKeyRequest {} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateLocalApiConfigRequest { + pub enabled: Option, + pub port: Option, + pub remote_access: Option, + pub cors_origins: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidateLocalApiKeyRequest { + pub api_key: String, + pub permission_code: String, +} diff --git a/src-tauri/crates/business/src/entitys/messages.rs b/src-tauri/crates/business/src/entitys/messages.rs new file mode 100644 index 00000000..6d9832a6 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/messages.rs @@ -0,0 +1,71 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 创建消息请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateMessageRequest { + pub message_type: String, // team_invitation, system_notification, etc. + pub title: String, + pub content: Option, // JSON 格式的扩展数据 + pub recipient_uuids: Vec, // 接收者列表(recipient_type='single' 或 'multiple' 时使用) + pub recipient_type: String, // single, multiple, team, all + pub related_type: Option, + pub related_uuid: Option, + pub priority: Option, // low, normal, high, urgent + pub metadata: Option, // JSON 格式的扩展信息 +} + +/// 查询消息列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListMessagesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 消息筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MessageFilters { + pub message_type: Option, + pub is_read: Option, + pub action_status: Option, + pub priority: Option, +} + +/// 标记消息为已读请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MarkMessageReadRequest { + pub message_uuid: Uuid, +} + +/// 批量标记已读请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchMarkReadRequest { + pub message_uuids: Vec, +} + +/// 处理消息请求(用于邀请类消息) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct HandleMessageRequest { + pub message_uuid: Uuid, + pub action: String, // accept, reject +} + +/// 消息列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct MessageListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 消息统计响应 +#[derive(Debug, Clone, Serialize)] +pub struct MessageStatsResponse { + pub total: i64, + pub unread: i64, + pub by_type: std::collections::HashMap, +} diff --git a/src-tauri/crates/business/src/entitys/mod.rs b/src-tauri/crates/business/src/entitys/mod.rs new file mode 100644 index 00000000..a328d8d7 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/mod.rs @@ -0,0 +1,41 @@ +pub mod user; + +// 新增模块 +pub mod accounts; +pub mod audit; +pub mod common; +pub mod environments; +pub mod group_member_permissions; +pub mod groups; +pub mod local_api; +pub mod messages; +pub mod proxies; +pub mod proxy_visible_teams; +pub mod rpa; +pub mod settings; +pub mod tags; +pub mod teams; +pub mod templates; +pub mod workspace_quotas; +pub mod workspaces; + +pub use user::*; + +// 新增导出 +pub use accounts::*; +pub use audit::*; +pub use common::*; +pub use environments::*; +pub use group_member_permissions::*; +pub use groups::*; +pub use local_api::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visible_teams::*; +pub use rpa::*; +pub use settings::*; +pub use tags::*; +pub use teams::*; +pub use templates::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src-tauri/crates/business/src/entitys/proxies.rs b/src-tauri/crates/business/src/entitys/proxies.rs new file mode 100644 index 00000000..40bafed8 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/proxies.rs @@ -0,0 +1,80 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询代理列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListProxiesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 代理筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ProxyFilters { + pub keyword: Option, + pub proxy_type: Option, + pub status: Option, + pub country: Option, +} + +/// 创建代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateProxyRequest { + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub country: Option, + pub city: Option, + pub ssh_key: Option, + pub ssh_passphrase: Option, +} + +/// 更新代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateProxyRequest { + pub uuid: Uuid, + pub name: Option, + pub host: Option, + pub port: Option, + pub proxy_type: Option, + pub username: Option, + pub password: Option, + pub country: Option, + pub city: Option, +} + +/// 批量导入代理项(客户端已解析好的结构化数据) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchImportProxyItem { + pub name: String, + pub host: String, + pub port: i32, + pub proxy_type: String, + pub username: Option, + pub password: Option, + pub country: Option, + pub city: Option, +} + +/// 批量导入代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchImportProxiesRequest { + pub proxies: Vec, +} + +// ========== 响应结构体 ========== + +/// 代理列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ProxyListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} diff --git a/src-tauri/crates/business/src/entitys/proxy_visible_teams.rs b/src-tauri/crates/business/src/entitys/proxy_visible_teams.rs new file mode 100644 index 00000000..687e4b53 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/proxy_visible_teams.rs @@ -0,0 +1,50 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 设置代理可见性请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SetProxyVisibleRequest { + pub proxy_uuid: Uuid, + pub team_uuid: Uuid, +} + +/// 移除代理可见性请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RemoveProxyVisibleRequest { + pub proxy_uuid: Uuid, + pub team_uuid: Uuid, +} + +/// 批量设置代理可见性请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchSetProxyVisibleRequest { + pub proxy_uuid: Uuid, + pub team_uuids: Vec, +} + +/// 查询代理可见团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListProxyVisibleTeamsRequest { + pub proxy_uuid: Uuid, +} + +/// 查询可见代理请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListVisibleProxiesRequest { + pub workspace_uuid: Uuid, + pub team_uuid: Option, +} + +// ========== 响应结构体 ========== + +/// 代理可见团队列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct ProxyVisibleTeamListResponse { + pub items: Vec, +} + +/// 可见代理列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct VisibleProxyListResponse { + pub items: Vec, +} diff --git a/src-tauri/crates/business/src/entitys/rpa.rs b/src-tauri/crates/business/src/entitys/rpa.rs new file mode 100644 index 00000000..47c54817 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/rpa.rs @@ -0,0 +1,135 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListRpaTasksRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RpaTaskFilters { + pub keyword: Option, + pub status: Option, + pub trigger_type: Option, + pub tags: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateRpaTaskRequest { + pub name: String, + pub description: Option, + pub tags: Option>, + pub trigger_type: String, + pub schedule: Option, + pub cron_expression: Option, + pub run_mode: String, + pub retry_count: Option, + pub retry_interval: Option, + pub timeout: Option, + pub concurrency: Option, + pub stop_on_error: Option, + pub notify_on_complete: Option, + pub notify_on_error: Option, + pub environment_uuids: Option>, + pub steps: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RpaTaskStepRequest { + pub step_type: String, + pub name: String, + pub config: serde_json::Value, + pub enabled: Option, + pub position_x: Option, + pub position_y: Option, + pub sort_order: Option, + pub next_step_uuid: Option, + pub branch_config: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateRpaTaskRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub tags: Option>, + pub trigger_type: Option, + pub schedule: Option, + pub cron_expression: Option, + pub run_mode: Option, + pub retry_count: Option, + pub retry_interval: Option, + pub timeout: Option, + pub concurrency: Option, + pub stop_on_error: Option, + pub notify_on_complete: Option, + pub notify_on_error: Option, + pub environment_uuids: Option>, + pub steps: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RunRpaTaskRequest { + pub uuid: Uuid, + pub environment_uuids: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DuplicateRpaTaskRequest { + pub uuid: Uuid, + pub new_name: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExportRpaTaskRequest { + pub uuid: Uuid, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ImportRpaTaskRequest { + pub import_data: String, + pub name: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListRpaRunsRequest { + pub task_uuid: Uuid, + #[serde(flatten)] + pub pagination: Pagination, + pub status: Option, +} + +use crate::dto::{RpaTaskDto, RpaTaskRunDto, RpaTaskStepDto}; + +#[derive(Debug, Clone, Serialize)] +pub struct RpaTaskListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RpaTaskDetailResponse { + pub task: RpaTaskDto, + pub steps: Vec, + pub environment_uuids: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RpaRunsListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ExportRpaTaskResponse { + pub content: String, + pub filename: String, +} diff --git a/src-tauri/crates/business/src/entitys/settings.rs b/src-tauri/crates/business/src/entitys/settings.rs new file mode 100644 index 00000000..7786cc19 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/settings.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +/// 更新用户偏好请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdatePreferencesRequest { + pub theme: Option, + pub language: Option, + pub notifications_enabled: Option, +} + +/// 更新用户信息请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateProfileRequest { + pub nickname: Option, + pub avatar_hash: Option, +} + +/// 修改密码请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ChangePasswordRequest { + pub old_password: String, + pub new_password: String, +} + +/// 查询登录历史请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListLoginHistoryRequest { + pub page: i32, + pub page_size: i32, +} diff --git a/src-tauri/crates/business/src/entitys/tags.rs b/src-tauri/crates/business/src/entitys/tags.rs new file mode 100644 index 00000000..49e6d29f --- /dev/null +++ b/src-tauri/crates/business/src/entitys/tags.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询标签列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListTagsRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub keyword: Option, +} + +/// 创建标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateTagRequest { + pub name: String, + pub color: Option, +} + +/// 更新标签请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateTagRequest { + pub uuid: Uuid, + pub name: Option, + pub color: Option, + pub sort_order: Option, +} diff --git a/src-tauri/crates/business/src/entitys/teams.rs b/src-tauri/crates/business/src/entitys/teams.rs new file mode 100644 index 00000000..e690ca35 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/teams.rs @@ -0,0 +1,130 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 创建团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateTeamRequest { + pub workspace_uuid: Uuid, + pub name: String, + pub description: Option, +} + +/// 更新团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateTeamRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub avatar_hash: Option, +} + +/// 切换团队请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SwitchTeamRequest { + pub team_uuid: Uuid, +} + +/// 查询团队成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListTeamMembersRequest { + pub workspace_uuid: Uuid, + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 团队成员筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TeamMemberFilters { + pub keyword: Option, + pub role: Option, + pub status: Option, +} + +/// 将另一个本地用户加入团队。 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AddMemberRequest { + pub user_uuid: Uuid, + pub role: String, +} + +/// 取消邀请请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CancelInviteRequest { + pub invitation_uuid: Uuid, +} + +/// 更新成员角色请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateMemberRoleRequest { + pub member_uuid: Uuid, + pub role: String, +} + +/// 更新成员状态请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateMemberStatusRequest { + pub member_uuid: Uuid, + pub status: String, +} + +/// 移除成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RemoveMemberRequest { + pub member_uuid: Uuid, +} + +/// 批量移除成员请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BatchRemoveMembersRequest { + pub member_uuids: Vec, +} + +/// 接受邀请请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AcceptInvitationRequest { + pub token: String, +} + +/// 拒绝邀请请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RejectInvitationRequest { + pub token: String, +} + +// ========== 响应结构体 ========== + +/// 团队列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct TeamListResponse { + pub current_team_uuid: Option, + pub teams: Vec, +} + +/// 团队列表项 +#[derive(Debug, Clone, Serialize)] +pub struct TeamItem { + pub uuid: Uuid, + pub name: String, + pub description: Option, + pub role: String, + pub members_count: i64, + pub is_current: bool, +} + +/// 团队成员列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct MemberListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 接受邀请响应 +#[derive(Debug, Clone, Serialize)] +pub struct AcceptInvitationResponse { + pub team_uuid: Uuid, +} diff --git a/src-tauri/crates/business/src/entitys/templates.rs b/src-tauri/crates/business/src/entitys/templates.rs new file mode 100644 index 00000000..66cdf325 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/templates.rs @@ -0,0 +1,94 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 查询模板列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListTemplatesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub keyword: Option, + pub is_public: Option, +} + +/// 创建模板请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateTemplateRequest { + pub name: String, + pub description: Option, + pub is_public: Option, + /// 完整的环境详情数据(EnvironmentDetailResponse 的 JSON 格式) + /// 如果提供了 environment_uuid,则此字段会被忽略,后端会自动获取环境详情 + pub environment_data: Option, + /// 环境 UUID(如果提供,后端会自动获取该环境的完整详情数据) + pub environment_uuid: Option, +} + +/// 更新模板请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateTemplateRequest { + pub uuid: Uuid, + pub name: Option, + pub description: Option, + pub is_public: Option, + pub config_json: Option, +} + +/// 应用模板请求(更新现有环境) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ApplyTemplateRequest { + pub template_uuid: Uuid, + pub environment_uuid: Uuid, +} + +/// 从模板创建环境请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateFromTemplateRequest { + pub template_uuid: Uuid, + pub name: Option, // 如果提供,将覆盖模板中的名称 + pub description: Option, // 如果提供,将覆盖模板中的描述 + pub group_uuid: Option, // 如果提供,将覆盖模板中的分组 +} + +/// 获取模板详情请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetTemplateRequest { + pub uuid: Uuid, + /// 是否用于创建环境(如果为 true,将检查关联数据是否存在) + pub for_create: Option, +} + +// ========== 响应结构体 ========== + +/// 模板列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct TemplateListResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +/// 关联数据状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssociationsStatus { + /// 分组是否存在 + pub group_exists: bool, + /// 标签是否存在(按 UUID 映射) + pub tags_exist: std::collections::HashMap, + /// 账号是否存在(按 UUID 映射) + pub accounts_exist: std::collections::HashMap, + /// 代理是否存在 + pub proxy_exists: bool, +} + +/// 模板详情响应(包含关联数据状态) +#[derive(Debug, Clone, Serialize)] +pub struct TemplateDetailResponse { + /// 模板数据 + #[serde(flatten)] + pub template: crate::dto::TemplateDto, + /// 关联数据状态(仅在 for_create=true 时返回) + pub associations_status: Option, +} diff --git a/src-tauri/crates/business/src/entitys/user.rs b/src-tauri/crates/business/src/entitys/user.rs new file mode 100644 index 00000000..9623fffc --- /dev/null +++ b/src-tauri/crates/business/src/entitys/user.rs @@ -0,0 +1,132 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 注册请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct RegisterRequest { + pub email: String, + pub password: String, + pub code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub nickname: Option, // 昵称(可选) + #[serde(skip_serializing_if = "Option::is_none")] + pub public_secret_key: Option, // 用户公钥(可选) + #[serde(skip_serializing_if = "Option::is_none")] + pub referral_code: Option, // 推荐码(可选) +} + +/// 基本登录请求(邮箱 + 密码) +#[derive(Debug, Deserialize, Serialize)] +pub struct BasicLoginData { + pub email: String, + pub password: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_secret_key: Option, +} + +/// 记住密码登录请求(邮箱 + refresh_token) +#[derive(Debug, Deserialize, Serialize)] +pub struct RememberLoginData { + pub email: String, + pub refresh_token: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_secret_key: Option, +} + +/// 登录请求(统一结构,通过枚举区分两种登录方式) +/// +/// 使用 serde 的 tag 特性,根据 "login_type" 字段自动反序列化为对应的变体 +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "login_type", rename_all = "snake_case")] +pub enum LoginRequest { + /// 基本登录(邮箱 + 密码) + Basic(BasicLoginData), + /// 记住密码登录(邮箱 + refresh_token) + Remember(RememberLoginData), +} + +/// 刷新 Token 请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct RefreshTokenRequest { + pub refresh_token: String, +} + +/// 更新用户信息请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct UpdateUserRequest { + pub nickname: Option, + pub phone: Option, + pub email: Option, +} + +/// 修改密码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct UpdatePasswordRequest { + pub old_password: String, + pub new_password: String, +} + +/// 校验当前用户密码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct VerifyPasswordRequest { + pub password: String, +} + +/// 重置密码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct ResetPasswordRequest { + pub email: String, + pub code: String, + pub new_password: String, +} + +/// 发送验证码请求 +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct SendCodeRequest { + pub email: String, + pub r#type: String, // register 或 reset_password +} + +/// 用户信息响应 +#[derive(Debug, Serialize)] +pub struct UserResponse { + pub uuid: Uuid, + pub id: String, + pub nickname: Option, + pub email: String, + pub phone: Option, + pub avatar_hash: Option, + /// 头像完整 URL(当 avatar_hash 存在时由服务端拼接) + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + /// 当前团队详细信息(如果存在) + pub current_team: Option, + /// 当前工作空间详细信息(如果存在) + pub current_workspace: Option, +} + +/// 登录响应 +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub access_token: String, + pub refresh_token: String, + pub user_info: Option, +} + +/// 注册响应 +#[derive(Debug, Serialize)] +pub struct RegisterResponse { + pub access_token: String, + pub refresh_token: String, + pub user_info: Option, +} + +/// 密码校验响应 +#[derive(Debug, Serialize)] +pub struct VerifyPasswordResponse { + pub valid: bool, +} diff --git a/src-tauri/crates/business/src/entitys/workspace_quotas.rs b/src-tauri/crates/business/src/entitys/workspace_quotas.rs new file mode 100644 index 00000000..2346bf8f --- /dev/null +++ b/src-tauri/crates/business/src/entitys/workspace_quotas.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 获取工作空间配额请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetWorkspaceQuotaRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_uuid: Option, +} + +/// 更新配额使用情况请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateQuotaUsageRequest { + pub workspace_uuid: Uuid, + pub quota_type: String, // 'environments', 'proxies', 'team_members', 'rpa_tasks' + pub increment: bool, // true 为增加,false 为减少 + pub amount: i32, // 增加或减少的数量 +} + +// ========== 响应结构体 ========== + +/// 工作空间配额响应 +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceQuotaResponse { + pub workspace_uuid: Uuid, + pub max_environments: i32, + pub used_environments: i32, + pub max_team_members: i32, + pub used_team_members: i32, + pub max_proxies: i32, + pub used_proxies: i32, + pub max_rpa_tasks: i32, + pub used_rpa_tasks: i32, +} diff --git a/src-tauri/crates/business/src/entitys/workspaces.rs b/src-tauri/crates/business/src/entitys/workspaces.rs new file mode 100644 index 00000000..72f43674 --- /dev/null +++ b/src-tauri/crates/business/src/entitys/workspaces.rs @@ -0,0 +1,57 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::Pagination; + +/// 创建工作空间请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreateWorkspaceRequest { + pub name: String, + pub workspace_type: Option, +} + +/// 更新工作空间请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UpdateWorkspaceRequest { + pub uuid: Uuid, + pub name: Option, +} + +/// 切换工作空间请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SwitchWorkspaceRequest { + pub workspace_uuid: Uuid, +} + +/// 查询工作空间列表请求 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ListWorkspacesRequest { + #[serde(flatten)] + pub pagination: Pagination, + pub filters: Option, +} + +/// 工作空间筛选条件 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WorkspaceFilters { + pub workspace_type: Option, + pub keyword: Option, +} + +// ========== 响应结构体 ========== + +/// 工作空间列表响应 +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceListResponse { + pub current_workspace_uuid: Option, + pub workspaces: Vec, +} + +/// 工作空间列表项 +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceItem { + pub uuid: Uuid, + pub name: String, + pub workspace_type: String, + pub is_current: bool, +} diff --git a/src-tauri/crates/business/src/errors.rs b/src-tauri/crates/business/src/errors.rs new file mode 100644 index 00000000..6d22fa1c --- /dev/null +++ b/src-tauri/crates/business/src/errors.rs @@ -0,0 +1,105 @@ +use thiserror::Error; + +/// Simprint Server 错误类型 +#[derive(Error, Debug)] +pub enum SimprintError { + /// 用户不存在 + #[error("用户不存在")] + UserNotFound, + + /// 邮箱已被注册 + #[error("邮箱已被注册")] + EmailAlreadyExists, + + /// 邮箱或密码错误 + #[error("邮箱或密码错误")] + InvalidCredentials, + + /// 用户已被禁用 + #[error("用户已被禁用")] + UserDisabled, + + /// 验证码错误或已过期 + #[error("验证码错误或已过期")] + VerificationCodeExpired, + + /// 机器不存在 + #[error("机器不存在")] + MachineNotFound, + + /// 机器已被绑定 + #[error("机器已被绑定")] + MachineAlreadyBound, + + /// 用户未绑定到机器 + #[error("用户未绑定到机器")] + MachineNotBound, + + /// 版本不存在 + #[error("版本不存在")] + VersionNotFound, + + /// 版本已存在 + #[error("版本已存在")] + VersionAlreadyExists, + + /// 版本类型不存在 + #[error("版本类型不存在")] + VersionTypeNotFound, + + /// 版本号为空 + #[error("版本号不能为空")] + VersionEmpty, + + /// 资源名称为空 + #[error("资源名称不能为空")] + ResourceNameEmpty, + + /// 灰度发布不存在 + #[error("灰度发布不存在")] + GrayReleaseNotFound, + + /// 灰度分配失败 + #[error("灰度分配失败")] + GrayAllocationFailed, + + /// 策略类型不存在 + #[error("策略类型不存在")] + StrategyTypeNotFound, + + /// 策略配置无效 + #[error("策略配置无效")] + InvalidStrategyConfig, + + /// 维护不存在 + #[error("维护不存在")] + MaintenanceNotFound, + + /// 数据库操作失败 + #[error("数据库操作失败: {0}")] + DatabaseError(#[from] sqlx::Error), + + /// Anyhow错误 + #[error("操作失败: {0}")] + AnyhowError(#[from] anyhow::Error), + + /// JSON序列化错误 + #[error("JSON序列化错误: {0}")] + JsonError(#[from] serde_json::Error), + + /// 错误的请求 + #[error("错误的请求: {0}")] + InvalidRequest(String), + + /// 其他错误 + #[error("{0}")] + Other(String), +} + +impl From<&str> for SimprintError { + fn from(err: &str) -> Self { + SimprintError::Other(err.to_string()) + } +} + +// Note: Error conversion to Response is handled in handlers layer via map_err diff --git a/src-tauri/crates/business/src/lib.rs b/src-tauri/crates/business/src/lib.rs new file mode 100644 index 00000000..d8ff38e7 --- /dev/null +++ b/src-tauri/crates/business/src/lib.rs @@ -0,0 +1,10 @@ +pub mod database; +pub mod dispatcher; +pub mod dto; +pub mod entitys; +pub mod errors; +pub mod models; +pub mod services; +pub mod state; +pub mod svc_ctx; +pub mod utils; diff --git a/src-tauri/crates/business/src/models/accounts.rs b/src-tauri/crates/business/src/models/accounts.rs new file mode 100644 index 00000000..a6458457 --- /dev/null +++ b/src-tauri/crates/business/src/models/accounts.rs @@ -0,0 +1,321 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool, placeholders}; + +use crate::dto::PlatformAccountDto; + +/// 创建平台账号 +pub async fn insert_platform_account( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + platform_url: &str, + platform_name: Option<&str>, + account: &str, + password: Option<&str>, + remark: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO platform_accounts (user_uuid, team_uuid, platform_url, platform_name, + account, password, remark) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(platform_url) + .bind(platform_name) + .bind(account) + .bind(password) + .bind(remark) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询平台账号列表 +pub async fn fetch_platform_accounts( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + keyword: Option<&str>, + platform_name: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let keyword = keyword.map(|value| format!("%{}%", value.trim())); + + let recs = sqlx::query_as::<_, PlatformAccountDto>( + r#" + SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, + pa.account, pa.password, pa.status, pa.remark, pa.usage_count, + (SELECT COUNT(*) FROM environment_accounts ea WHERE ea.account_uuid = pa.uuid) AS environments_count, + pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at + FROM platform_accounts pa + WHERE (pa.team_uuid = $1 OR (pa.team_uuid IS NULL AND pa.user_uuid = $2)) + AND ( + $3 IS NULL + OR pa.platform_url LIKE $3 + OR COALESCE(pa.platform_name, '') LIKE $3 + OR pa.account LIKE $3 + OR COALESCE(pa.remark, '') LIKE $3 + ) + AND ($4 IS NULL OR pa.platform_name = $4) + AND ($5 IS NULL OR pa.status = $5) + AND pa.deleted_at IS NULL + ORDER BY pa.created_at DESC + LIMIT $6 OFFSET $7 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(keyword) + .bind(platform_name) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询平台账号总数 +pub async fn fetch_platform_accounts_count( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + keyword: Option<&str>, + platform_name: Option<&str>, + status: Option<&str>, +) -> Result { + let keyword = keyword.map(|value| format!("%{}%", value.trim())); + + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM platform_accounts + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND ( + $3 IS NULL + OR platform_url LIKE $3 + OR COALESCE(platform_name, '') LIKE $3 + OR account LIKE $3 + OR COALESCE(remark, '') LIKE $3 + ) + AND ($4 IS NULL OR platform_name = $4) + AND ($5 IS NULL OR status = $5) + AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(keyword) + .bind(platform_name) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询平台账号 +pub async fn fetch_platform_account_by_uuid( + pool: &Pool, + account_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, PlatformAccountDto>( + r#" + SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, + pa.account, pa.password, pa.status, pa.remark, pa.usage_count, + (SELECT COUNT(*) FROM environment_accounts ea WHERE ea.account_uuid = pa.uuid) AS environments_count, + pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at + FROM platform_accounts pa + WHERE pa.uuid = $1 AND pa.deleted_at IS NULL + "#, + ) + .bind(account_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新平台账号 +pub async fn update_platform_account( + pool: &Pool, + account_uuid: Uuid, + platform_url: Option<&str>, + platform_name: Option<&str>, + account: Option<&str>, + password: Option<&str>, + remark: Option<&str>, + status: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE platform_accounts + SET platform_url = COALESCE($1, platform_url), + platform_name = COALESCE($2, platform_name), + account = COALESCE($3, account), + password = COALESCE($4, password), + remark = COALESCE($5, remark), + status = COALESCE($6, status) + WHERE uuid = $7 AND deleted_at IS NULL + "#, + ) + .bind(platform_url) + .bind(platform_name) + .bind(account) + .bind(password) + .bind(remark) + .bind(status) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加账号使用次数 +pub async fn increment_account_usage(pool: &Pool, account_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE platform_accounts + SET usage_count = usage_count + 1, last_used_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除平台账号 +pub async fn delete_platform_account(pool: &Pool, account_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE platform_accounts SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除平台账号 +pub async fn batch_delete_platform_accounts( + pool: &Pool, + account_uuids: &[Uuid], +) -> Result { + if account_uuids.is_empty() { + return Ok(0); + } + + let statement = format!( + r#" + UPDATE platform_accounts SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid IN ({}) AND deleted_at IS NULL + "#, + placeholders(1, account_uuids.len()) + ); + let mut query = sqlx::query(&statement); + for uuid in account_uuids { + query = query.bind(uuid); + } + let result = query.execute(pool).await?; + + Ok(result.rows_affected()) +} + +// ============ Environment Accounts ============ + +/// 关联环境和账号 +pub async fn insert_environment_account( + pool: &Pool, + env_uuid: Uuid, + account_uuid: Uuid, + sort_order: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO environment_accounts (environment_uuid, account_uuid, sort_order) + VALUES ($1, $2, $3) + ON CONFLICT (environment_uuid, account_uuid) DO UPDATE SET sort_order = $3; + "#, + ) + .bind(env_uuid) + .bind(account_uuid) + .bind(sort_order) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除环境和账号的关联 +pub async fn remove_environment_account( + pool: &Pool, + env_uuid: Uuid, + account_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_accounts + WHERE environment_uuid = $1 AND account_uuid = $2; + "#, + ) + .bind(env_uuid) + .bind(account_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清空环境的所有账号关联 +pub async fn clear_environment_accounts(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_accounts WHERE environment_uuid = $1; + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询环境关联的所有账号 +pub async fn fetch_environment_accounts( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, PlatformAccountDto>( + r#" + SELECT pa.id, pa.uuid, pa.user_uuid, pa.team_uuid, pa.platform_url, pa.platform_name, + pa.account, pa.password, pa.status, pa.remark, pa.usage_count, + (SELECT COUNT(*) FROM environment_accounts ea2 WHERE ea2.account_uuid = pa.uuid) AS environments_count, + pa.last_used_at, pa.created_at, pa.updated_at, pa.deleted_at + FROM platform_accounts pa + INNER JOIN environment_accounts ea ON pa.uuid = ea.account_uuid + WHERE ea.environment_uuid = $1 AND pa.deleted_at IS NULL + ORDER BY ea.sort_order + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src-tauri/crates/business/src/models/audit.rs b/src-tauri/crates/business/src/models/audit.rs new file mode 100644 index 00000000..5bf8b530 --- /dev/null +++ b/src-tauri/crates/business/src/models/audit.rs @@ -0,0 +1,242 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool}; + +use crate::dto::AuditLogDto; + +/// 记录审计日志 +pub async fn insert_audit_log( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + action: &str, + target_type: &str, + target_uuid: Option, + target_name: Option<&str>, + details: Option<&str>, + changes: Option<&serde_json::Value>, + ip_address: Option<&str>, + user_agent: Option<&str>, + request_id: Option<&str>, +) -> Result { + let id: i64 = sqlx::query_scalar( + r#" + INSERT INTO audit_logs (user_uuid, team_uuid, action, target_type, target_uuid, + target_name, details, changes, ip_address, user_agent, request_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(action) + .bind(target_type) + .bind(target_uuid) + .bind(target_name) + .bind(details) + .bind(changes) + .bind(ip_address) + .bind(user_agent) + .bind(request_id) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询审计日志列表 +/// +/// 查询逻辑: +/// - 当前团队的所有审计日志 +/// - 加上当前用户的个人操作日志(team_uuid 为空的,如登录、注册等) +pub async fn fetch_audit_logs( + pool: &Pool, + current_user_uuid: Uuid, + team_uuid: Option, + user_uuid_filter: Option, + keyword: Option<&str>, + action: Option<&str>, + target_type: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, AuditLogDto>( + r#" + SELECT + a.id, a.uuid, a.user_uuid, a.team_uuid, a.action, a.target_type, a.target_uuid, + a.target_name, a.details, a.changes, a.ip_address, a.user_agent, a.request_id, a.created_at, + ui.nickname AS user_name, ui.email AS user_email + FROM audit_logs a + LEFT JOIN user_infos ui ON a.user_uuid = ui.user_uuid + WHERE ( + ($2 IS NULL OR a.team_uuid = $2) + OR (a.team_uuid IS NULL AND a.user_uuid = $1) + ) + AND ($3 IS NULL OR a.user_uuid = $3) + AND ($4 IS NULL OR COALESCE(a.details, '') LIKE '%' || $4 || '%') + AND ($5 IS NULL OR a.action = $5) + AND ($6 IS NULL OR a.target_type = $6) + ORDER BY a.created_at DESC + LIMIT $7 OFFSET $8 + "#, + ) + .bind(current_user_uuid) + .bind(team_uuid) + .bind(user_uuid_filter) + .bind(keyword) + .bind(action) + .bind(target_type) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询审计日志总数 +pub async fn fetch_audit_logs_count( + pool: &Pool, + current_user_uuid: Uuid, + team_uuid: Option, + user_uuid_filter: Option, + keyword: Option<&str>, + action: Option<&str>, + target_type: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM audit_logs + WHERE ( + ($2 IS NULL OR team_uuid = $2) + OR (team_uuid IS NULL AND user_uuid = $1) + ) + AND ($3 IS NULL OR user_uuid = $3) + AND ($4 IS NULL OR COALESCE(details, '') LIKE '%' || $4 || '%') + AND ($5 IS NULL OR action = $5) + AND ($6 IS NULL OR target_type = $6) + "#, + ) + .bind(current_user_uuid) + .bind(team_uuid) + .bind(user_uuid_filter) + .bind(keyword) + .bind(action) + .bind(target_type) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询审计日志 +pub async fn fetch_audit_log_by_uuid( + pool: &Pool, + log_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, AuditLogDto>( + r#" + SELECT + a.id, a.uuid, a.user_uuid, a.team_uuid, a.action, a.target_type, a.target_uuid, + a.target_name, a.details, a.changes, a.ip_address, a.user_agent, a.request_id, a.created_at, + ui.nickname AS user_name, ui.email AS user_email + FROM audit_logs a + LEFT JOIN user_infos ui ON a.user_uuid = ui.user_uuid + WHERE a.uuid = $1 + "#, + ) + .bind(log_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询指定日期的审计日志数量 +pub async fn fetch_audit_logs_count_by_date( + pool: &Pool, + team_uuid: Option, + date: chrono::NaiveDate, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM audit_logs + WHERE ($1 IS NULL OR team_uuid = $1) + AND created_at::date = $2 + "#, + ) + .bind(team_uuid) + .bind(date) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 查询指定日期之后的审计日志数量 +pub async fn fetch_audit_logs_count_since_date( + pool: &Pool, + team_uuid: Option, + since_date: chrono::NaiveDate, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM audit_logs + WHERE ($1 IS NULL OR team_uuid = $1) + AND created_at::date >= $2 + "#, + ) + .bind(team_uuid) + .bind(since_date) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 查询热门操作类型 +pub async fn fetch_top_actions( + pool: &Pool, + team_uuid: Option, + limit: i64, +) -> Result, Error> { + let rows: Vec<(String, i64)> = sqlx::query_as( + r#" + SELECT action, COUNT(*) as count FROM audit_logs + WHERE ($1 IS NULL OR team_uuid = $1) + GROUP BY action + ORDER BY count DESC + LIMIT $2 + "#, + ) + .bind(team_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(rows) +} + +/// 查询热门目标类型 +pub async fn fetch_top_target_types( + pool: &Pool, + team_uuid: Option, + limit: i64, +) -> Result, Error> { + let rows: Vec<(String, i64)> = sqlx::query_as( + r#" + SELECT target_type, COUNT(*) as count FROM audit_logs + WHERE ($1 IS NULL OR team_uuid = $1) + GROUP BY target_type + ORDER BY count DESC + LIMIT $2 + "#, + ) + .bind(team_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(rows) +} diff --git a/src-tauri/crates/business/src/models/environments.rs b/src-tauri/crates/business/src/models/environments.rs new file mode 100644 index 00000000..7ec93281 --- /dev/null +++ b/src-tauri/crates/business/src/models/environments.rs @@ -0,0 +1,1709 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool, placeholders}; + +use crate::dto::{ + EnvironmentAccountRowDto, EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentDto, + EnvironmentRowDto, EnvironmentTagRowDto, EnvironmentUrlDto, GroupDto, GroupRowDto, ProxyRowDto, + TagDto, TemplateDto, +}; +use crate::entitys::CookieInput; + +// ============ Groups ============ + +/// 创建分组 +pub async fn insert_group( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + name: &str, + description: Option<&str>, + created_by: Uuid, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO groups (workspace_uuid, team_uuid, name, description, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(created_by) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询分组列表(工作空间级别) +pub async fn fetch_groups( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupDto>( + r#" + SELECT g.id, g.uuid, g.workspace_uuid, g.team_uuid, t.name AS team_name, + g.name, g.description, g.sort_order, + g.created_by, ui.nickname AS created_by_name, + (SELECT COUNT(*) FROM environments e WHERE e.group_uuid = g.uuid AND e.deleted_at IS NULL) AS environments_count, + g.created_at, g.updated_at, g.deleted_at + FROM groups g + LEFT JOIN teams t ON g.team_uuid = t.uuid + LEFT JOIN user_infos ui ON g.created_by = ui.user_uuid + WHERE g.workspace_uuid = $1 AND g.team_uuid = $2 AND g.deleted_at IS NULL + ORDER BY g.sort_order, g.name + LIMIT $3 OFFSET $4 + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据 UUID 查询分组 +pub async fn fetch_group_by_uuid( + pool: &Pool, + group_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, GroupDto>( + r#" + SELECT g.id, g.uuid, g.workspace_uuid, g.team_uuid, t.name AS team_name, + g.name, g.description, g.sort_order, + g.created_by, ui.nickname AS created_by_name, + (SELECT COUNT(*) FROM environments e WHERE e.group_uuid = g.uuid AND e.deleted_at IS NULL) AS environments_count, + g.created_at, g.updated_at, g.deleted_at + FROM groups g + LEFT JOIN teams t ON g.team_uuid = t.uuid + LEFT JOIN user_infos ui ON g.created_by = ui.user_uuid + WHERE g.uuid = $1 AND g.deleted_at IS NULL + "#, + ) + .bind(group_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新分组 +pub async fn update_group( + pool: &Pool, + group_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + sort_order: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE groups + SET name = COALESCE($1, name), + description = COALESCE($2, description), + sort_order = COALESCE($3, sort_order) + WHERE uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(sort_order) + .bind(group_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除分组 +pub async fn delete_group(pool: &Pool, group_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE groups SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(group_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Tags ============ + +/// 创建标签 +pub async fn insert_tag( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + name: &str, + color: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO tags (user_uuid, team_uuid, name, color) + VALUES ($1, $2, $3, $4) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(color.unwrap_or("gray")) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询标签列表 +pub async fn fetch_tags( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TagDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, color, sort_order, + environments_count, created_at, updated_at, deleted_at + FROM tags + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND deleted_at IS NULL + ORDER BY sort_order, name + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 根据 UUID 查询标签 +pub async fn fetch_tag_by_uuid(pool: &Pool, tag_uuid: Uuid) -> Result, Error> { + let rec = sqlx::query_as::<_, TagDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, color, sort_order, + environments_count, created_at, updated_at, deleted_at + FROM tags + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(tag_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新标签 +pub async fn update_tag( + pool: &Pool, + tag_uuid: Uuid, + name: Option<&str>, + color: Option<&str>, + sort_order: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE tags + SET name = COALESCE($1, name), + color = COALESCE($2, color), + sort_order = COALESCE($3, sort_order) + WHERE uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(color) + .bind(sort_order) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除标签 +pub async fn delete_tag(pool: &Pool, tag_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE tags SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Environments ============ + +/// 创建环境 +pub async fn insert_environment( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Uuid, + name: &str, + description: Option<&str>, + group_uuid: Option, + proxy_uuid: Option, + system_info: Option<&str>, + kernel_info: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO environments (workspace_uuid, user_uuid, team_uuid, name, description, + group_uuid, proxy_uuid, system_info, kernel_info) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(group_uuid) + .bind(proxy_uuid) + .bind(system_info) + .bind(kernel_info) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询环境列表(基础信息) +pub async fn fetch_environments_base( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + status: Option<&str>, + keyword: Option<&str>, + tag_uuids: Option<&[Uuid]>, + offset: i64, + limit: i64, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT DISTINCT + e.id, e.uuid, e.workspace_uuid, e.user_uuid, e.team_uuid, e.name, e.description, e.status, + e.system_info, e.kernel_info, e.fingerprint_summary, + e.group_uuid, e.proxy_uuid, + e.last_opened_at, e.created_at, e.updated_at + FROM environments e + "#, + ); + + // 如果有标签过滤,需要 JOIN environment_tags 表 + if tag_uuids.is_some() { + query.push_str(" LEFT JOIN environment_tags et ON e.uuid = et.environment_uuid "); + } + + query.push_str( + r#" + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + // 分组过滤 + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + // 状态过滤 + if status.is_some() { + conditions.push(format!("e.status = ${}", param_index)); + param_index += 1; + } + + // 关键词搜索 + if keyword.is_some() { + conditions.push(format!( + "(LOWER(e.name) LIKE LOWER(${}) OR CAST(e.uuid AS TEXT) LIKE LOWER(${}))", + param_index, param_index + )); + param_index += 1; + } + + // 标签过滤 + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + conditions.push(format!( + "et.tag_uuid IN ({})", + placeholders(param_index, tags.len()) + )); + param_index += tags.len(); + } + } + + // 添加所有条件 + for condition in conditions { + query.push_str(&format!(" AND {}", condition)); + } + + query.push_str(&format!( + " ORDER BY e.created_at DESC LIMIT ${} OFFSET ${}", + param_index, + param_index + 1 + )); + + // 构建查询 + let mut sql_query = sqlx::query_as::<_, EnvironmentRowDto>(&query) + .bind(workspace_uuid) + .bind(team_uuid); + + // 绑定参数 + if let Some(g) = group_uuid { + sql_query = sql_query.bind(g); + } + if let Some(s) = status { + sql_query = sql_query.bind(s); + } + if let Some(k) = keyword { + let search_pattern = format!("%{}%", k); + sql_query = sql_query.bind(search_pattern); + } + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + for tag_uuid in tags { + sql_query = sql_query.bind(tag_uuid); + } + } + } + + sql_query = sql_query.bind(limit).bind(offset); + + let recs = sql_query.fetch_all(pool).await?; + + Ok(recs) +} + +/// 查询环境列表(基础) +pub async fn fetch_environments( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, EnvironmentDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, + status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, + last_opened_at, created_at, updated_at, deleted_at + FROM environments + WHERE workspace_uuid = $1 AND team_uuid = $2 + AND ($3 IS NULL OR group_uuid = $3) + AND ($4 IS NULL OR status = $4) + AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT $5 OFFSET $6 + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(group_uuid) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询环境总数 +pub async fn fetch_environments_count( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + status: Option<&str>, + keyword: Option<&str>, + tag_uuids: Option<&[Uuid]>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(DISTINCT e.id) FROM environments e + "#, + ); + + // 如果有标签过滤,需要 JOIN environment_tags 表 + if tag_uuids.is_some() { + query.push_str(" LEFT JOIN environment_tags et ON e.uuid = et.environment_uuid "); + } + + query.push_str( + r#" + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + // 分组过滤 + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + // 状态过滤 + if status.is_some() { + conditions.push(format!("e.status = ${}", param_index)); + param_index += 1; + } + + // 关键词搜索 + if keyword.is_some() { + conditions.push(format!( + "(LOWER(e.name) LIKE LOWER(${}) OR CAST(e.uuid AS TEXT) LIKE LOWER(${}))", + param_index, param_index + )); + param_index += 1; + } + + // 标签过滤 + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + conditions.push(format!( + "et.tag_uuid IN ({})", + placeholders(param_index, tags.len()) + )); + } + } + + // 添加所有条件 + for condition in conditions { + query.push_str(&format!(" AND {}", condition)); + } + + // 构建查询 + let mut sql_query = sqlx::query_scalar::<_, i64>(&query).bind(workspace_uuid).bind(team_uuid); + + // 绑定参数 + if let Some(g) = group_uuid { + sql_query = sql_query.bind(g); + } + if let Some(s) = status { + sql_query = sql_query.bind(s); + } + if let Some(k) = keyword { + let search_pattern = format!("%{}%", k); + sql_query = sql_query.bind(search_pattern); + } + if let Some(tags) = tag_uuids { + if !tags.is_empty() { + for tag_uuid in tags { + sql_query = sql_query.bind(tag_uuid); + } + } + } + + let count = sql_query.fetch_one(pool).await?; + + Ok(count) +} + +/// 根据 UUID 查询环境(不带工作空间过滤,用于内部查询) +pub async fn fetch_environment_by_uuid_unfiltered( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, EnvironmentDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, + status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, + last_opened_at, created_at, updated_at, deleted_at + FROM environments + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 根据 UUID 查询环境(带工作空间过滤) +pub async fn fetch_environment_by_uuid( + pool: &Pool, + workspace_uuid: Uuid, + env_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, EnvironmentDto>( + r#" + SELECT id, uuid, workspace_uuid, user_uuid, team_uuid, name, description, + status, group_uuid, proxy_uuid, system_info, kernel_info, fingerprint_summary, + last_opened_at, created_at, updated_at, deleted_at + FROM environments + WHERE uuid = $1 AND workspace_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新环境基础信息 +pub async fn update_environment( + pool: &Pool, + env_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + group_uuid: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments + SET name = COALESCE($1, name), + description = COALESCE($2, description), + group_uuid = $3 + WHERE uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(group_uuid) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn update_environment_kernel_info( + pool: &Pool, + env_uuid: Uuid, + kernel_info: &str, +) -> Result<(), Error> { + sqlx::query( + "UPDATE environments SET kernel_info = $1, updated_at = CURRENT_TIMESTAMP \ + WHERE uuid = $2 AND deleted_at IS NULL", + ) + .bind(kernel_info) + .bind(env_uuid) + .execute(pool) + .await?; + Ok(()) +} + +/// 更新环境状态 +pub async fn update_environment_status( + pool: &Pool, + env_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET status = $1 + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新环境代理 +pub async fn update_environment_proxy( + pool: &Pool, + env_uuid: Uuid, + proxy_uuid: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET proxy_uuid = $1 + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新环境最后打开时间 +pub async fn update_environment_last_opened(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET last_opened_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除环境 +pub async fn delete_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除环境 +pub async fn batch_delete_environments(pool: &Pool, env_uuids: &[Uuid]) -> Result { + if env_uuids.is_empty() { + return Ok(0); + } + + let statement = format!( + r#" + UPDATE environments SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid IN ({}) AND deleted_at IS NULL + "#, + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + let result = query.execute(pool).await?; + + Ok(result.rows_affected()) +} + +// ============ Recycle Bin ============ + +/// 查询回收站环境列表(已删除但未永久删除) +pub async fn fetch_deleted_environments_base( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + keyword: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT DISTINCT + e.id, e.uuid, e.workspace_uuid, e.user_uuid, e.team_uuid, e.name, e.description, e.status, + e.system_info, e.kernel_info, e.fingerprint_summary, + e.group_uuid, e.proxy_uuid, + e.last_opened_at, e.created_at, e.updated_at, e.deleted_at + FROM environments e + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NOT NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + // 分组过滤 + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + // 关键词搜索 + if keyword.is_some() { + conditions.push(format!( + "(LOWER(e.name) LIKE LOWER(${}) OR CAST(e.uuid AS TEXT) LIKE LOWER(${}))", + param_index, param_index + )); + param_index += 1; + } + + if !conditions.is_empty() { + query.push_str(" AND "); + query.push_str(&conditions.join(" AND ")); + } + + query.push_str(" ORDER BY e.deleted_at DESC LIMIT $"); + query.push_str(¶m_index.to_string()); + param_index += 1; + query.push_str(" OFFSET $"); + query.push_str(¶m_index.to_string()); + + let mut q = sqlx::query_as::<_, EnvironmentRowDto>(&query) + .bind(workspace_uuid) + .bind(team_uuid); + + if let Some(gid) = group_uuid { + q = q.bind(gid); + } + + if let Some(kw) = keyword { + let pattern = format!("%{}%", kw); + q = q.bind(pattern); + } + + q = q.bind(limit).bind(offset); + + let recs = q.fetch_all(pool).await?; + Ok(recs) +} + +/// 统计回收站环境总数 +pub async fn fetch_deleted_environments_count( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + group_uuid: Option, + keyword: Option<&str>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(DISTINCT e.id) + FROM environments e + WHERE e.workspace_uuid = $1 + AND e.team_uuid = $2 + AND e.deleted_at IS NOT NULL + "#, + ); + + let mut param_index = 3; + let mut conditions = Vec::new(); + + if group_uuid.is_some() { + conditions.push(format!("e.group_uuid = ${}", param_index)); + param_index += 1; + } + + if keyword.is_some() { + conditions.push(format!( + "(LOWER(e.name) LIKE LOWER(${}) OR CAST(e.uuid AS TEXT) LIKE LOWER(${}))", + param_index, param_index + )); + } + + if !conditions.is_empty() { + query.push_str(" AND "); + query.push_str(&conditions.join(" AND ")); + } + + let mut q = sqlx::query_scalar::<_, i64>(&query).bind(workspace_uuid).bind(team_uuid); + + if let Some(gid) = group_uuid { + q = q.bind(gid); + } + + if let Some(kw) = keyword { + let pattern = format!("%{}%", kw); + q = q.bind(pattern); + } + + let count = q.fetch_one(pool).await?; + Ok(count) +} + +/// 恢复环境(将 deleted_at 设为 NULL) +pub async fn restore_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE environments SET deleted_at = NULL + WHERE uuid = $1 AND deleted_at IS NOT NULL + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量恢复环境 +pub async fn batch_restore_environments(pool: &Pool, env_uuids: &[Uuid]) -> Result { + if env_uuids.is_empty() { + return Ok(0); + } + + let statement = format!( + r#" + UPDATE environments SET deleted_at = NULL + WHERE uuid IN ({}) AND deleted_at IS NOT NULL + "#, + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + let result = query.execute(pool).await?; + + Ok(result.rows_affected()) +} + +/// 永久删除环境(真正的 DELETE) +pub async fn permanent_delete_environment(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + // 先删除关联数据 + sqlx::query("DELETE FROM environment_tags WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_urls WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_cookies WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_configs WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + sqlx::query("DELETE FROM environment_accounts WHERE environment_uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + // `environment_extensions` 已在扩展系统重构后移除。 + // 环境的扩展现在通过 user/team/group 绑定动态合并,不再需要清理环境级关联。 + + // 最后删除环境本身 + sqlx::query("DELETE FROM environments WHERE uuid = $1") + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量永久删除环境 +pub async fn batch_permanent_delete_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result { + if env_uuids.is_empty() { + return Ok(0); + } + + // 先删除关联数据 + for table in [ + "environment_tags", + "environment_urls", + "environment_cookies", + "environment_configs", + "environment_accounts", + ] { + delete_environment_relations(pool, table, env_uuids).await?; + } + + // `environment_extensions` 已在扩展系统重构后移除。 + // 环境的扩展现在通过 user/team/group 绑定动态合并,不再需要清理环境级关联。 + + // 最后删除环境本身 + let statement = format!( + "DELETE FROM environments WHERE uuid IN ({})", + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + let result = query.execute(pool).await?; + + Ok(result.rows_affected()) +} + +async fn delete_environment_relations( + pool: &Pool, + table: &str, + env_uuids: &[Uuid], +) -> Result<(), Error> { + let statement = format!( + "DELETE FROM {table} WHERE environment_uuid IN ({})", + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + query.execute(pool).await?; + Ok(()) +} + +// ============ Environment Configs ============ + +/// 创建或更新环境配置 +pub async fn upsert_environment_config( + pool: &Pool, + env_uuid: Uuid, + window_info: &serde_json::Value, + basic_settings: &serde_json::Value, + fingerprint_settings: &serde_json::Value, + device_settings: &serde_json::Value, + preference_settings: &serde_json::Value, + project_metadata: &serde_json::Value, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO environment_configs (environment_uuid, window_info, basic_settings, + fingerprint_settings, device_settings, preference_settings, + project_metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (environment_uuid) DO UPDATE SET + window_info = $2, + basic_settings = $3, + fingerprint_settings = $4, + device_settings = $5, + preference_settings = $6, + project_metadata = $7 + RETURNING id; + "#, + ) + .bind(env_uuid) + .bind(window_info) + .bind(basic_settings) + .bind(fingerprint_settings) + .bind(device_settings) + .bind(preference_settings) + .bind(project_metadata) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询环境配置 +pub async fn fetch_environment_config( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, EnvironmentConfigDto>( + r#" + SELECT id, environment_uuid, window_info, basic_settings, fingerprint_settings, + device_settings, preference_settings, project_metadata, created_at, updated_at + FROM environment_configs + WHERE environment_uuid = $1 + "#, + ) + .bind(env_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 批量查询环境配置 +pub async fn fetch_environment_configs_by_uuids( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let statement = format!( + r#" + SELECT id, environment_uuid, window_info, basic_settings, fingerprint_settings, + device_settings, preference_settings, project_metadata, created_at, updated_at + FROM environment_configs + WHERE environment_uuid IN ({}) + "#, + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query_as::<_, EnvironmentConfigDto>(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + let recs = query.fetch_all(pool).await?; + + Ok(recs) +} + +// ============ Environment Tags ============ + +/// 为环境添加标签 +pub async fn insert_environment_tag( + pool: &Pool, + env_uuid: Uuid, + tag_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO environment_tags (environment_uuid, tag_uuid) + VALUES ($1, $2) + ON CONFLICT DO NOTHING; + "#, + ) + .bind(env_uuid) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除环境的标签 +pub async fn remove_environment_tag( + pool: &Pool, + env_uuid: Uuid, + tag_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_tags + WHERE environment_uuid = $1 AND tag_uuid = $2; + "#, + ) + .bind(env_uuid) + .bind(tag_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清除环境的所有标签 +pub async fn clear_environment_tags(pool: &Pool, env_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_tags + WHERE environment_uuid = $1; + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询环境的所有标签 +pub async fn fetch_environment_tags(pool: &Pool, env_uuid: Uuid) -> Result, Error> { + let recs = sqlx::query_as::<_, TagDto>( + r#" + SELECT t.id, t.uuid, t.user_uuid, t.team_uuid, t.name, t.color, t.sort_order, + t.environments_count, t.created_at, t.updated_at, t.deleted_at + FROM tags t + INNER JOIN environment_tags et ON t.uuid = et.tag_uuid + WHERE et.environment_uuid = $1 AND t.deleted_at IS NULL + ORDER BY t.sort_order, t.name + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询环境标签(完整标签信息) +pub async fn fetch_tags_for_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let statement = format!( + r#" + SELECT + et.environment_uuid, + t.id as tag_id, + t.uuid as tag_uuid, + t.name as tag_name, + t.color as tag_color, + t.sort_order as tag_sort_order, + t.user_uuid as tag_user_uuid, + t.team_uuid as tag_team_uuid, + t.environments_count as tag_environments_count, + t.created_at as tag_created_at, + t.updated_at as tag_updated_at, + t.deleted_at as tag_deleted_at + FROM environment_tags et + INNER JOIN tags t ON et.tag_uuid = t.uuid + WHERE et.environment_uuid IN ({}) AND t.deleted_at IS NULL + ORDER BY t.sort_order, t.name + "#, + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query_as::<_, EnvironmentTagRowDto>(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + let recs = query.fetch_all(pool).await?; + + Ok(recs) +} + +/// 批量查询环境账号(完整账号信息,排除敏感数据) +pub async fn fetch_accounts_for_environments( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let statement = format!( + r#" + SELECT + ea.environment_uuid, + pa.id as account_id, + pa.uuid as account_uuid, + pa.platform_url, + pa.platform_name, + pa.account, + pa.status as account_status, + pa.remark + FROM environment_accounts ea + INNER JOIN platform_accounts pa ON ea.account_uuid = pa.uuid + WHERE ea.environment_uuid IN ({}) AND pa.deleted_at IS NULL + ORDER BY ea.sort_order + "#, + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query_as::<_, EnvironmentAccountRowDto>(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + let recs = query.fetch_all(pool).await?; + + Ok(recs) +} + +/// 批量查询分组 +pub async fn fetch_groups_by_uuids( + pool: &Pool, + group_uuids: &[Uuid], +) -> Result, Error> { + if group_uuids.is_empty() { + return Ok(vec![]); + } + + let statement = format!( + r#" + SELECT id, uuid, name, description, sort_order + FROM groups + WHERE uuid IN ({}) AND deleted_at IS NULL + "#, + placeholders(1, group_uuids.len()) + ); + let mut query = sqlx::query_as::<_, GroupRowDto>(&statement); + for uuid in group_uuids { + query = query.bind(uuid); + } + let recs = query.fetch_all(pool).await?; + + Ok(recs) +} + +/// 批量查询代理 +pub async fn fetch_proxies_by_uuids( + pool: &Pool, + proxy_uuids: &[Uuid], +) -> Result, Error> { + if proxy_uuids.is_empty() { + return Ok(vec![]); + } + + let statement = format!( + r#" + SELECT id, uuid, name, host, port, proxy_type, + username, password, + country, city, status, latency, last_check_ip + FROM proxies + WHERE uuid IN ({}) AND deleted_at IS NULL + "#, + placeholders(1, proxy_uuids.len()) + ); + let mut query = sqlx::query_as::<_, ProxyRowDto>(&statement); + for uuid in proxy_uuids { + query = query.bind(uuid); + } + let recs = query.fetch_all(pool).await?; + + Ok(recs) +} + +// ============ Templates ============ + +/// 创建模板 +pub async fn insert_template( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + name: &str, + description: Option<&str>, + is_public: bool, + system_info: Option<&str>, + kernel_info: Option<&str>, + config_json: &serde_json::Value, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO templates (user_uuid, team_uuid, name, description, is_public, + system_info, kernel_info, config_json) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(is_public) + .bind(system_info) + .bind(kernel_info) + .bind(config_json) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询模板列表 +pub async fn fetch_templates( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + is_public: Option, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TemplateDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, is_public, + system_info, kernel_info, config_json, usage_count, created_at, updated_at, deleted_at + FROM templates + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2) OR is_public = TRUE) + AND ($3 IS NULL OR is_public = $3) + AND deleted_at IS NULL + ORDER BY usage_count DESC, created_at DESC + LIMIT $4 OFFSET $5 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(is_public) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询模板总数 +pub async fn fetch_templates_count( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + is_public: Option, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM templates + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2) OR is_public = TRUE) + AND ($3 IS NULL OR is_public = $3) + AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(is_public) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询模板 +pub async fn fetch_template_by_uuid( + pool: &Pool, + template_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TemplateDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, is_public, + system_info, kernel_info, config_json, usage_count, created_at, updated_at, deleted_at + FROM templates + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(template_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新模板 +pub async fn update_template( + pool: &Pool, + template_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + is_public: Option, + config_json: Option<&serde_json::Value>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE templates + SET name = COALESCE($1, name), + description = COALESCE($2, description), + is_public = COALESCE($3, is_public), + config_json = COALESCE($4, config_json) + WHERE uuid = $5 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(is_public) + .bind(config_json) + .bind(template_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加模板使用次数 +pub async fn increment_template_usage(pool: &Pool, template_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE templates SET usage_count = usage_count + 1 + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(template_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除模板 +pub async fn delete_template(pool: &Pool, template_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE templates SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(template_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Environment URLs ============ + +/// 添加环境 URL +pub async fn insert_environment_url( + pool: &Pool, + env_uuid: Uuid, + url: &str, + title: Option<&str>, + sort_order: Option, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO environment_urls (environment_uuid, url, title, sort_order) + VALUES ($1, $2, $3, $4) + RETURNING id; + "#, + ) + .bind(env_uuid) + .bind(url) + .bind(title) + .bind(sort_order.unwrap_or(0)) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 批量添加环境 URL +pub async fn batch_insert_environment_urls( + pool: &Pool, + env_uuid: Uuid, + urls: &[(String, Option)], +) -> Result { + let mut count = 0; + for (idx, (url, title)) in urls.iter().enumerate() { + sqlx::query( + r#" + INSERT INTO environment_urls (environment_uuid, url, title, sort_order) + VALUES ($1, $2, $3, $4) + ON CONFLICT DO NOTHING; + "#, + ) + .bind(env_uuid) + .bind(url) + .bind(title.as_deref()) + .bind(idx as i32) + .execute(pool) + .await?; + count += 1; + } + Ok(count) +} + +/// 查询环境的所有 URL +pub async fn fetch_environment_urls( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, EnvironmentUrlDto>( + r#" + SELECT id, environment_uuid, url, title, sort_order, created_at + FROM environment_urls + WHERE environment_uuid = $1 + ORDER BY sort_order, id + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询环境的所有 URL +pub async fn fetch_environment_urls_by_uuids( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let statement = format!( + r#" + SELECT id, environment_uuid, url, title, sort_order, created_at + FROM environment_urls + WHERE environment_uuid IN ({}) + ORDER BY environment_uuid, sort_order, id + "#, + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query_as::<_, EnvironmentUrlDto>(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + let recs = query.fetch_all(pool).await?; + + Ok(recs) +} + +/// 删除环境的 URL +pub async fn delete_environment_url(pool: &Pool, url_id: i32) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_urls WHERE id = $1 + "#, + ) + .bind(url_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清空环境的所有 URL +pub async fn clear_environment_urls(pool: &Pool, env_uuid: Uuid) -> Result { + let result = sqlx::query( + r#" + DELETE FROM environment_urls WHERE environment_uuid = $1 + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie +pub async fn insert_environment_cookie( + pool: &Pool, + env_uuid: Uuid, + site_input: &str, + domain: &str, + name: &str, + value: &str, + path: Option<&str>, + http_only: Option, + secure: Option, + same_site: Option<&str>, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO environment_cookies (environment_uuid, site_input, domain, name, value, path, http_only, secure, same_site) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id; + "#, + ) + .bind(env_uuid) + .bind(site_input) + .bind(domain) + .bind(name) + .bind(value) + .bind(path.unwrap_or("/")) + .bind(http_only.unwrap_or(false)) + .bind(secure.unwrap_or(false)) + .bind(same_site.unwrap_or("Lax")) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 批量添加环境 Cookies +pub async fn batch_insert_environment_cookies( + pool: &Pool, + env_uuid: Uuid, + cookies: &[CookieInput], +) -> Result { + let mut count = 0; + for cookie in cookies { + sqlx::query( + r#" + INSERT INTO environment_cookies (environment_uuid, site_input, domain, name, value, path, http_only, secure, same_site) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT DO NOTHING; + "#, + ) + .bind(env_uuid) + .bind(&cookie.site_input) + .bind(&cookie.domain) + .bind(&cookie.name) + .bind(&cookie.value) + .bind(cookie.path.as_deref().unwrap_or("/")) + .bind(cookie.http_only.unwrap_or(false)) + .bind(cookie.secure.unwrap_or(false)) + .bind(cookie.same_site.as_deref().unwrap_or("Lax")) + .execute(pool) + .await?; + count += 1; + } + Ok(count) +} + +/// 查询环境的所有 Cookies +pub async fn fetch_environment_cookies( + pool: &Pool, + env_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, EnvironmentCookieDto>( + r#" + SELECT id, environment_uuid, site_input, domain, name, value, path, expires_at, http_only, secure, same_site, created_at + FROM environment_cookies + WHERE environment_uuid = $1 + ORDER BY site_input, domain, name + "#, + ) + .bind(env_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 批量查询环境的所有 Cookies +pub async fn fetch_environment_cookies_by_uuids( + pool: &Pool, + env_uuids: &[Uuid], +) -> Result, Error> { + if env_uuids.is_empty() { + return Ok(vec![]); + } + + let statement = format!( + r#" + SELECT id, environment_uuid, site_input, domain, name, value, path, expires_at, http_only, secure, same_site, created_at + FROM environment_cookies + WHERE environment_uuid IN ({}) + ORDER BY environment_uuid, site_input, domain, name + "#, + placeholders(1, env_uuids.len()) + ); + let mut query = sqlx::query_as::<_, EnvironmentCookieDto>(&statement); + for uuid in env_uuids { + query = query.bind(uuid); + } + let recs = query.fetch_all(pool).await?; + + Ok(recs) +} + +/// 删除环境的 Cookie +pub async fn delete_environment_cookie(pool: &Pool, cookie_id: i32) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM environment_cookies WHERE id = $1 + "#, + ) + .bind(cookie_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清空环境的所有 Cookies +pub async fn clear_environment_cookies(pool: &Pool, env_uuid: Uuid) -> Result { + let result = sqlx::query( + r#" + DELETE FROM environment_cookies WHERE environment_uuid = $1 + "#, + ) + .bind(env_uuid) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} diff --git a/src-tauri/crates/business/src/models/group_member_permissions.rs b/src-tauri/crates/business/src/models/group_member_permissions.rs new file mode 100644 index 00000000..55ae9cc7 --- /dev/null +++ b/src-tauri/crates/business/src/models/group_member_permissions.rs @@ -0,0 +1,199 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool}; + +use crate::dto::GroupMemberPermissionDto; + +/// 授予分组权限 +pub async fn grant_group_permission( + pool: &Pool, + group_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + permission_type: &str, + granted_by: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO group_member_permissions ( + group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by + ) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (group_uuid, user_uuid) DO UPDATE SET + permission_type = EXCLUDED.permission_type, + granted_by = EXCLUDED.granted_by, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(user_uuid) + .bind(permission_type) + .bind(granted_by) + .execute(pool) + .await?; + + Ok(()) +} + +/// 撤销分组权限 +pub async fn revoke_group_permission( + pool: &Pool, + group_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM group_member_permissions + WHERE group_uuid = $1 AND user_uuid = $2 + "#, + ) + .bind(group_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询用户的分组权限列表 +pub async fn fetch_user_group_permissions( + pool: &Pool, + user_uuid: Uuid, + workspace_uuid: Option, + group_uuid: Option, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupMemberPermissionDto>( + r#" + SELECT group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by, + created_at, updated_at + FROM group_member_permissions + WHERE user_uuid = $1 + AND ($2 IS NULL OR workspace_uuid = $2) + AND ($3 IS NULL OR group_uuid = $3) + ORDER BY created_at DESC + "#, + ) + .bind(user_uuid) + .bind(workspace_uuid) + .bind(group_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 检查用户是否有分组权限(工作空间级别) +pub async fn check_group_permission( + pool: &Pool, + workspace_uuid: Uuid, + group_uuid: Uuid, + user_uuid: Uuid, + permission_type: &str, +) -> Result { + // 首先检查用户是否是团队成员,以及是否是 Owner/Admin(自动拥有所有权限) + let is_owner_or_admin: bool = sqlx::query_scalar( + r#" + SELECT EXISTS ( + SELECT 1 FROM team_members tm + INNER JOIN groups g ON tm.team_uuid = g.team_uuid AND tm.workspace_uuid = g.workspace_uuid + WHERE g.uuid = $1 + AND g.workspace_uuid = $2 + AND tm.workspace_uuid = $2 + AND tm.user_uuid = $3 + AND tm.role IN ('owner', 'admin') + AND tm.deleted_at IS NULL + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + if is_owner_or_admin { + return Ok(true); + } + + // 检查显式权限(工作空间级别) + let has_permission = match permission_type { + "read" => { + // read 权限:检查是否有 read/write/manage 任一权限 + sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 FROM group_member_permissions + WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await? + } + "write" => { + // write 权限:检查是否有 write/manage 权限 + sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 FROM group_member_permissions + WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 + AND permission_type IN ('write', 'manage') + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await? + } + "manage" => { + // manage 权限:检查是否有 manage 权限 + sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 FROM group_member_permissions + WHERE group_uuid = $1 AND workspace_uuid = $2 AND user_uuid = $3 + AND permission_type = 'manage' + ) + "#, + ) + .bind(group_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await? + } + _ => false, + }; + + Ok(has_permission) +} + +/// 查询分组的所有权限 +pub async fn fetch_group_permissions( + pool: &Pool, + group_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, GroupMemberPermissionDto>( + r#" + SELECT group_uuid, workspace_uuid, team_uuid, user_uuid, permission_type, granted_by, + created_at, updated_at + FROM group_member_permissions + WHERE group_uuid = $1 + ORDER BY created_at DESC + "#, + ) + .bind(group_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src-tauri/crates/business/src/models/local_api.rs b/src-tauri/crates/business/src/models/local_api.rs new file mode 100644 index 00000000..b66df59c --- /dev/null +++ b/src-tauri/crates/business/src/models/local_api.rs @@ -0,0 +1,338 @@ +use chrono::{DateTime, Utc}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool}; + +use crate::dto::{ + LocalApiKeyDto, LocalApiKeyPermissionDto, LocalApiPermissionDefinitionDto, LocalApiSettingsDto, +}; + +pub async fn fetch_local_api_settings( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiSettingsDto>( + r#" + SELECT id, uuid, user_uuid, enabled, port, remote_access, cors_origins, created_at, updated_at, deleted_at + FROM user_local_api_settings + WHERE user_uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await +} + +pub async fn upsert_local_api_settings( + pool: &Pool, + user_uuid: Uuid, + enabled: Option, + port: Option, + remote_access: Option, + cors_origins: Option<&Value>, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_local_api_settings (user_uuid, enabled, port, remote_access, cors_origins) + VALUES ($1, COALESCE($2, FALSE), COALESCE($3, 8080), COALESCE($4, FALSE), COALESCE($5, '[]')) + ON CONFLICT (user_uuid) DO UPDATE SET + enabled = COALESCE($2, user_local_api_settings.enabled), + port = COALESCE($3, user_local_api_settings.port), + remote_access = COALESCE($4, user_local_api_settings.remote_access), + cors_origins = COALESCE($5, user_local_api_settings.cors_origins), + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(enabled) + .bind(port) + .bind(remote_access) + .bind(cors_origins) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn fetch_active_api_key( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiKeyDto>( + r#" + SELECT + id, uuid, user_uuid, key_prefix, key_hash, api_key, is_active, requests_today, + daily_limit, last_reset_date, last_used_at, expires_at, created_at, updated_at, deleted_at + FROM user_local_api_keys + WHERE user_uuid = $1 AND is_active = TRUE AND deleted_at IS NULL + ORDER BY id DESC + LIMIT 1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await +} + +pub async fn fetch_api_key_by_hash( + pool: &Pool, + key_hash: &str, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiKeyDto>( + r#" + SELECT + id, uuid, user_uuid, key_prefix, key_hash, api_key, is_active, requests_today, + daily_limit, last_reset_date, last_used_at, expires_at, created_at, updated_at, deleted_at + FROM user_local_api_keys + WHERE key_hash = $1 AND is_active = TRUE AND deleted_at IS NULL + LIMIT 1 + "#, + ) + .bind(key_hash) + .fetch_optional(pool) + .await +} + +pub async fn deactivate_api_keys_for_user(pool: &Pool, user_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_local_api_keys + SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $1 AND is_active = TRUE AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn insert_api_key( + pool: &Pool, + user_uuid: Uuid, + key_prefix: &str, + key_hash: &str, + api_key: &str, + daily_limit: i32, +) -> Result { + sqlx::query_as::<_, LocalApiKeyDto>( + r#" + INSERT INTO user_local_api_keys (user_uuid, key_prefix, key_hash, api_key, daily_limit) + VALUES ($1, $2, $3, $4, $5) + RETURNING + id, uuid, user_uuid, key_prefix, key_hash, api_key, is_active, requests_today, + daily_limit, last_reset_date, last_used_at, expires_at, created_at, updated_at, deleted_at + "#, + ) + .bind(user_uuid) + .bind(key_prefix) + .bind(key_hash) + .bind(api_key) + .bind(daily_limit) + .fetch_one(pool) + .await +} + +pub async fn fetch_api_key_permission( + pool: &Pool, + api_key_id: i32, + permission_code: &str, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiKeyPermissionDto>( + r#" + SELECT + id, uuid, api_key_id, permission_code, is_enabled, rate_limit_per_minute, + rate_limit_per_hour, created_at, updated_at, deleted_at + FROM user_local_api_key_permissions + WHERE api_key_id = $1 + AND permission_code = $2 + AND deleted_at IS NULL + LIMIT 1 + "#, + ) + .bind(api_key_id) + .bind(permission_code) + .fetch_optional(pool) + .await +} + +pub async fn insert_api_key_permission( + pool: &Pool, + api_key_id: i32, + permission_code: &str, + rate_limit_per_minute: i32, + rate_limit_per_hour: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_local_api_key_permissions ( + api_key_id, permission_code, is_enabled, rate_limit_per_minute, rate_limit_per_hour + ) + VALUES ($1, $2, TRUE, $3, $4) + ON CONFLICT (api_key_id, permission_code) DO NOTHING + "#, + ) + .bind(api_key_id) + .bind(permission_code) + .bind(rate_limit_per_minute) + .bind(rate_limit_per_hour) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn fetch_permission_definition( + pool: &Pool, + permission_code: &str, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiPermissionDefinitionDto>( + r#" + SELECT + id, uuid, permission_code, name, description, default_enabled, + default_rate_limit_per_minute, default_rate_limit_per_hour, sort_order, + created_at, updated_at, deleted_at + FROM local_api_permission_definitions + WHERE permission_code = $1 AND deleted_at IS NULL + LIMIT 1 + "#, + ) + .bind(permission_code) + .fetch_optional(pool) + .await +} + +pub async fn fetch_permission_definitions( + pool: &Pool, +) -> Result, Error> { + sqlx::query_as::<_, LocalApiPermissionDefinitionDto>( + r#" + SELECT + id, uuid, permission_code, name, description, default_enabled, + default_rate_limit_per_minute, default_rate_limit_per_hour, sort_order, + created_at, updated_at, deleted_at + FROM local_api_permission_definitions + WHERE deleted_at IS NULL + ORDER BY sort_order ASC, id ASC + "#, + ) + .fetch_all(pool) + .await +} + +pub async fn reset_api_key_daily_usage( + pool: &Pool, + api_key_id: i32, + today: chrono::NaiveDate, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_local_api_keys + SET requests_today = 0, last_reset_date = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + "#, + ) + .bind(api_key_id) + .bind(today) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn increment_api_key_usage( + pool: &Pool, + api_key_id: i32, + used_at: DateTime, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_local_api_keys + SET requests_today = requests_today + 1, last_used_at = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + "#, + ) + .bind(api_key_id) + .bind(used_at) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn fetch_request_count( + pool: &Pool, + api_key_id: i32, + permission_code: &str, + window_type: &str, + window_start: DateTime, +) -> Result { + let count = sqlx::query_scalar::<_, i32>( + r#" + SELECT request_count + FROM user_local_api_request_counters + WHERE api_key_id = $1 + AND permission_code = $2 + AND window_type = $3 + AND window_start = $4 + LIMIT 1 + "#, + ) + .bind(api_key_id) + .bind(permission_code) + .bind(window_type) + .bind(window_start) + .fetch_optional(pool) + .await?; + + Ok(count.unwrap_or(0)) +} + +pub async fn increment_request_counter( + pool: &Pool, + api_key_id: i32, + permission_code: &str, + window_type: &str, + window_start: DateTime, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_local_api_request_counters ( + api_key_id, permission_code, window_type, window_start, request_count, updated_at + ) + VALUES ($1, $2, $3, $4, 1, CURRENT_TIMESTAMP) + ON CONFLICT (api_key_id, permission_code, window_type, window_start) DO UPDATE SET + request_count = user_local_api_request_counters.request_count + 1, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(api_key_id) + .bind(permission_code) + .bind(window_type) + .bind(window_start) + .execute(pool) + .await?; + + Ok(()) +} + +pub fn build_cors_origins_value(origins: &[String]) -> Value { + json!(origins) +} + +pub fn parse_cors_origins(value: &Value) -> Vec { + value + .as_array() + .map(|items| items.iter().filter_map(|item| item.as_str().map(ToOwned::to_owned)).collect()) + .unwrap_or_default() +} + +pub fn hash_api_key(api_key: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(api_key.as_bytes()); + hex::encode(hasher.finalize()) +} diff --git a/src-tauri/crates/business/src/models/messages.rs b/src-tauri/crates/business/src/models/messages.rs new file mode 100644 index 00000000..8330a9fd --- /dev/null +++ b/src-tauri/crates/business/src/models/messages.rs @@ -0,0 +1,413 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool}; + +use crate::dto::{MessageDto, UserMessageDto}; + +// ============ Messages ============ + +/// 创建消息 +pub async fn create_message( + pool: &Pool, + sender_uuid: Option, + message_type: &str, + title: &str, + content: Option<&str>, + recipient_type: &str, + related_type: Option<&str>, + related_uuid: Option, + priority: &str, + metadata: Option, +) -> Result { + let message_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO messages (message_type, title, content, sender_uuid, recipient_type, related_type, related_uuid, priority, metadata, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active') + RETURNING uuid; + "#, + ) + .bind(message_type) + .bind(title) + .bind(content) + .bind(sender_uuid) + .bind(recipient_type) + .bind(related_type) + .bind(related_uuid) + .bind(priority) + .bind(metadata) + .fetch_one(pool) + .await?; + + Ok(message_uuid) +} + +/// 添加消息接收者 +pub async fn add_message_recipient( + pool: &Pool, + message_uuid: Uuid, + user_uuid: Uuid, + action_status: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) + VALUES ($1, $2, FALSE, $3) + ON CONFLICT (message_uuid, user_uuid) DO NOTHING; + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .bind(action_status) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量添加消息接收者 +pub async fn add_message_recipients( + pool: &Pool, + message_uuid: Uuid, + user_uuids: &[Uuid], + action_status: Option<&str>, +) -> Result<(), Error> { + let mut tx = pool.begin().await?; + + for user_uuid in user_uuids { + sqlx::query( + r#" + INSERT INTO user_messages (message_uuid, user_uuid, is_read, action_status) + VALUES ($1, $2, FALSE, $3) + ON CONFLICT (message_uuid, user_uuid) DO NOTHING; + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .bind(action_status) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + Ok(()) +} + +/// 查询用户消息列表 +pub async fn fetch_user_messages( + pool: &Pool, + user_uuid: Uuid, + offset: i64, + limit: i64, + message_type: Option<&str>, + is_read: Option, + action_status: Option<&str>, + priority: Option<&str>, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT + m.uuid AS message_uuid, + m.message_type, + m.title, + m.content, + m.sender_uuid, + m.related_type, + m.related_uuid, + m.metadata, + m.priority, + m.created_at AS message_created_at, + um.is_read, + um.read_at, + um.action_status, + um.action_at, + ui.nickname AS sender_name, + ui.email AS sender_email + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + LEFT JOIN user_infos ui ON m.sender_uuid = ui.user_uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + "#, + ); + + let mut param_count = 1; + + if message_type.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.message_type = ${}", param_count)); + } + + if is_read.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.is_read = ${}", param_count)); + } + + if action_status.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.action_status = ${}", param_count)); + } + + if priority.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.priority = ${}", param_count)); + } + + query.push_str(" ORDER BY m.created_at DESC"); + param_count += 1; + query.push_str(&format!(" LIMIT ${}", param_count)); + param_count += 1; + query.push_str(&format!(" OFFSET ${}", param_count)); + + // 构建查询参数 + let mut query_builder = sqlx::query_as::<_, UserMessageDto>(&query); + query_builder = query_builder.bind(user_uuid); + + if let Some(mt) = message_type { + query_builder = query_builder.bind(mt); + } + if let Some(ir) = is_read { + query_builder = query_builder.bind(ir); + } + if let Some(as_) = action_status { + query_builder = query_builder.bind(as_); + } + if let Some(p) = priority { + query_builder = query_builder.bind(p); + } + + query_builder = query_builder.bind(limit); + query_builder = query_builder.bind(offset); + + let recs = query_builder.fetch_all(pool).await?; + + Ok(recs) +} + +/// 查询用户消息总数 +pub async fn fetch_user_messages_count( + pool: &Pool, + user_uuid: Uuid, + message_type: Option<&str>, + is_read: Option, + action_status: Option<&str>, + priority: Option<&str>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(*) + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + "#, + ); + + let mut param_count = 1; + + if message_type.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.message_type = ${}", param_count)); + } + + if is_read.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.is_read = ${}", param_count)); + } + + if action_status.is_some() { + param_count += 1; + query.push_str(&format!(" AND um.action_status = ${}", param_count)); + } + + if priority.is_some() { + param_count += 1; + query.push_str(&format!(" AND m.priority = ${}", param_count)); + } + + let mut query_builder = sqlx::query_scalar::<_, i64>(&query); + query_builder = query_builder.bind(user_uuid); + + if let Some(mt) = message_type { + query_builder = query_builder.bind(mt); + } + if let Some(ir) = is_read { + query_builder = query_builder.bind(ir); + } + if let Some(as_) = action_status { + query_builder = query_builder.bind(as_); + } + if let Some(p) = priority { + query_builder = query_builder.bind(p); + } + + let count = query_builder.fetch_one(pool).await?; + + Ok(count) +} + +/// 标记消息为已读 +pub async fn mark_message_read( + pool: &Pool, + message_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_messages + SET is_read = TRUE, read_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE message_uuid = $1 AND user_uuid = $2 AND is_read = FALSE + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量标记消息为已读 +pub async fn batch_mark_messages_read( + pool: &Pool, + message_uuids: &[Uuid], + user_uuid: Uuid, +) -> Result<(), Error> { + let mut tx = pool.begin().await?; + + for message_uuid in message_uuids { + sqlx::query( + r#" + UPDATE user_messages + SET is_read = TRUE, read_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE message_uuid = $1 AND user_uuid = $2 AND is_read = FALSE + "#, + ) + .bind(message_uuid) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + Ok(()) +} + +/// 处理消息(接受/拒绝) +pub async fn handle_message( + pool: &Pool, + message_uuid: Uuid, + user_uuid: Uuid, + action: &str, +) -> Result<(), Error> { + let action_status = match action { + "accept" => "accepted", + "reject" => "rejected", + _ => return Err(Error::RowNotFound), + }; + + sqlx::query( + r#" + UPDATE user_messages + SET action_status = $1, action_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE message_uuid = $2 AND user_uuid = $3 + "#, + ) + .bind(action_status) + .bind(message_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 获取用户消息统计 +pub async fn fetch_user_message_stats( + pool: &Pool, + user_uuid: Uuid, +) -> Result<(i64, i64, std::collections::HashMap), Error> { + // 总消息数 + let total: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + // 未读消息数 + let unread: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND um.is_read = FALSE AND m.deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + // 按类型统计 + let type_stats: Vec<(String, i64)> = sqlx::query_as( + r#" + SELECT m.message_type, COUNT(*) as count + FROM user_messages um + INNER JOIN messages m ON um.message_uuid = m.uuid + WHERE um.user_uuid = $1 AND m.deleted_at IS NULL + GROUP BY m.message_type + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + let mut by_type = std::collections::HashMap::new(); + for (msg_type, count) in type_stats { + by_type.insert(msg_type, count); + } + + Ok((total, unread, by_type)) +} + +/// 删除消息(软删除) +pub async fn delete_message(pool: &Pool, message_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE messages + SET deleted_at = CURRENT_TIMESTAMP, status = 'deleted', updated_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(message_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 根据 UUID 查询消息 +pub async fn fetch_message_by_uuid( + pool: &Pool, + message_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, MessageDto>( + r#" + SELECT id, uuid, message_type, title, content, sender_uuid, recipient_type, + related_type, related_uuid, metadata, status, priority, + created_at, updated_at, deleted_at + FROM messages + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(message_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} diff --git a/src-tauri/crates/business/src/models/mod.rs b/src-tauri/crates/business/src/models/mod.rs new file mode 100644 index 00000000..3216160b --- /dev/null +++ b/src-tauri/crates/business/src/models/mod.rs @@ -0,0 +1,31 @@ +pub mod user; + +// 新增模块 +pub mod accounts; +pub mod audit; +pub mod environments; +pub mod group_member_permissions; +pub mod local_api; +pub mod messages; +pub mod preferences; +pub mod proxies; +pub mod proxy_visible_teams; +pub mod rpa; +pub mod teams; +pub mod workspace_quotas; +pub mod workspaces; + +pub use user::*; + +// 新增导出 +pub use accounts::*; +pub use audit::*; +pub use environments::*; +pub use group_member_permissions::*; +pub use local_api::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visible_teams::*; +pub use teams::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src-tauri/crates/business/src/models/preferences.rs b/src-tauri/crates/business/src/models/preferences.rs new file mode 100644 index 00000000..ae530f53 --- /dev/null +++ b/src-tauri/crates/business/src/models/preferences.rs @@ -0,0 +1,54 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool}; + +use crate::dto::UserPreferenceDto; + +/// 获取用户偏好设置 +pub async fn fetch_user_preferences( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserPreferenceDto>( + r#" + SELECT id, user_uuid, theme, language, notifications_enabled, created_at, updated_at + FROM user_preferences + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 创建或更新用户偏好设置 +pub async fn upsert_user_preferences( + pool: &Pool, + user_uuid: Uuid, + theme: Option<&str>, + language: Option<&str>, + notifications_enabled: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO user_preferences (user_uuid, theme, language, notifications_enabled) + VALUES ($1, COALESCE($2, 'system'), COALESCE($3, 'zh-CN'), COALESCE($4, true)) + ON CONFLICT (user_uuid) DO UPDATE SET + theme = COALESCE($2, user_preferences.theme), + language = COALESCE($3, user_preferences.language), + notifications_enabled = COALESCE($4, user_preferences.notifications_enabled), + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(user_uuid) + .bind(theme) + .bind(language) + .bind(notifications_enabled) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src-tauri/crates/business/src/models/proxies.rs b/src-tauri/crates/business/src/models/proxies.rs new file mode 100644 index 00000000..02922ae9 --- /dev/null +++ b/src-tauri/crates/business/src/models/proxies.rs @@ -0,0 +1,308 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool, placeholders}; + +use crate::dto::{ProxyDto, ProxyHealthCheckDto}; + +/// 创建代理 +pub async fn insert_proxy( + pool: &Pool, + workspace_uuid: Uuid, + owner_uuid: Uuid, + name: &str, + host: &str, + port: i32, + proxy_type: &str, + username: Option<&str>, + password: Option<&str>, + country: Option<&str>, + city: Option<&str>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO proxies (workspace_uuid, owner_uuid, name, host, port, proxy_type, + username, password, country, city) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(owner_uuid) + .bind(name) + .bind(host) + .bind(port) + .bind(proxy_type) + .bind(username) + .bind(password) + .bind(country) + .bind(city) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询代理列表 +pub async fn fetch_proxies( + pool: &Pool, + workspace_uuid: Uuid, + proxy_type: Option<&str>, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.workspace_uuid = $1 + AND ($2 IS NULL OR p.proxy_type = $2) + AND ($3 IS NULL OR p.status = $3) + AND p.deleted_at IS NULL + ORDER BY p.created_at DESC + LIMIT $4 OFFSET $5 + "#, + ) + .bind(workspace_uuid) + .bind(proxy_type) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询代理总数 +pub async fn fetch_proxies_count( + pool: &Pool, + workspace_uuid: Uuid, + proxy_type: Option<&str>, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM proxies + WHERE workspace_uuid = $1 + AND ($2 IS NULL OR proxy_type = $2) + AND ($3 IS NULL OR status = $3) + AND deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .bind(proxy_type) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询代理 +pub async fn fetch_proxy_by_uuid( + pool: &Pool, + proxy_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.uuid = $1 AND p.deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新代理 +pub async fn update_proxy( + pool: &Pool, + proxy_uuid: Uuid, + name: Option<&str>, + host: Option<&str>, + port: Option, + proxy_type: Option<&str>, + username: Option<&str>, + password: Option<&str>, + country: Option<&str>, + city: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies + SET name = COALESCE($1, name), + host = COALESCE($2, host), + port = COALESCE($3, port), + proxy_type = COALESCE($4, proxy_type), + username = COALESCE($5, username), + password = COALESCE($6, password), + country = COALESCE($7, country), + city = COALESCE($8, city) + WHERE uuid = $9 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(host) + .bind(port) + .bind(proxy_type) + .bind(username) + .bind(password) + .bind(country) + .bind(city) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新代理检测结果 +pub async fn update_proxy_check_result( + pool: &Pool, + proxy_uuid: Uuid, + status: &str, + latency: Option, + ip_address: Option<&str>, + country: Option<&str>, + city: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies + SET status = $1, + latency = $2, + last_check_ip = $3, + country = $4, + city = $5, + last_checked_at = CURRENT_TIMESTAMP + WHERE uuid = $6 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(latency) + .bind(ip_address) + .bind(country) + .bind(city) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加代理使用次数 +pub async fn increment_proxy_usage(pool: &Pool, proxy_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies SET usage_count = usage_count + 1 + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除代理 +pub async fn delete_proxy(pool: &Pool, proxy_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE proxies SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(proxy_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除代理 +pub async fn batch_delete_proxies(pool: &Pool, proxy_uuids: &[Uuid]) -> Result { + if proxy_uuids.is_empty() { + return Ok(0); + } + + let statement = format!( + r#" + UPDATE proxies SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid IN ({}) AND deleted_at IS NULL + "#, + placeholders(1, proxy_uuids.len()) + ); + let mut query = sqlx::query(&statement); + for uuid in proxy_uuids { + query = query.bind(uuid); + } + let result = query.execute(pool).await?; + + Ok(result.rows_affected()) +} + +// ============ Proxy Health Checks ============ + +/// 记录代理健康检查 +pub async fn insert_proxy_health_check( + pool: &Pool, + proxy_uuid: Uuid, + status: &str, + latency: Option, + ip_address: Option<&str>, + error_message: Option<&str>, +) -> Result { + let id: i64 = sqlx::query_scalar( + r#" + INSERT INTO proxy_health_checks (proxy_uuid, status, latency, ip_address, error_message) + VALUES ($1, $2, $3, $4, $5) + RETURNING id; + "#, + ) + .bind(proxy_uuid) + .bind(status) + .bind(latency) + .bind(ip_address) + .bind(error_message) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询代理健康检查历史 +pub async fn fetch_proxy_health_checks( + pool: &Pool, + proxy_uuid: Uuid, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyHealthCheckDto>( + r#" + SELECT id, proxy_uuid, status, latency, ip_address, error_message, checked_at + FROM proxy_health_checks + WHERE proxy_uuid = $1 + ORDER BY checked_at DESC + LIMIT $2 + "#, + ) + .bind(proxy_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src-tauri/crates/business/src/models/proxy_visible_teams.rs b/src-tauri/crates/business/src/models/proxy_visible_teams.rs new file mode 100644 index 00000000..4709bdf5 --- /dev/null +++ b/src-tauri/crates/business/src/models/proxy_visible_teams.rs @@ -0,0 +1,288 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool}; + +use crate::dto::{ProxyDto, ProxyVisibleTeamDto}; + +/// 添加代理可见团队 +pub async fn insert_proxy_visible_team( + pool: &Pool, + proxy_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO proxy_visible_teams (proxy_uuid, workspace_uuid, team_uuid) + VALUES ($1, $2, $3) + ON CONFLICT (proxy_uuid, team_uuid) DO NOTHING + "#, + ) + .bind(proxy_uuid) + .bind(workspace_uuid) + .bind(team_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除代理可见团队 +pub async fn remove_proxy_visible_team( + pool: &Pool, + proxy_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + DELETE FROM proxy_visible_teams + WHERE proxy_uuid = $1 AND team_uuid = $2 + "#, + ) + .bind(proxy_uuid) + .bind(team_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询代理的可见团队列表 +pub async fn fetch_visible_teams_by_proxy( + pool: &Pool, + proxy_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyVisibleTeamDto>( + r#" + SELECT proxy_uuid, workspace_uuid, team_uuid, created_at + FROM proxy_visible_teams + WHERE proxy_uuid = $1 + ORDER BY created_at + "#, + ) + .bind(proxy_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询团队可见的代理列表 +pub async fn fetch_visible_proxies_by_team( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + INNER JOIN proxy_visible_teams pvt ON p.uuid = pvt.proxy_uuid + WHERE pvt.workspace_uuid = $1 AND pvt.team_uuid = $2 + AND p.deleted_at IS NULL + ORDER BY p.created_at DESC + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 检查代理对团队是否可见 +pub async fn check_proxy_visibility( + pool: &Pool, + proxy_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM proxy_visible_teams + WHERE proxy_uuid = $1 AND workspace_uuid = $2 AND team_uuid = $3 + "#, + ) + .bind(proxy_uuid) + .bind(workspace_uuid) + .bind(team_uuid) + .fetch_one(pool) + .await?; + + Ok(count > 0) +} + +/// 查询工作空间所有可见的代理(包括工作空间 Owner 和代理所有者的代理) +pub async fn fetch_visible_proxies_for_user( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Option, +) -> Result, Error> { + // 工作空间 Owner 可以看到所有代理 + // 代理所有者可以看到自己的代理 + // 团队成员可以看到 proxy_visible_teams 中包含其团队的代理 + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT DISTINCT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.workspace_uuid = $1 + AND p.deleted_at IS NULL + AND ( + -- 工作空间 Owner + EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.uuid = $1 AND w.owner_uuid = $2 AND w.deleted_at IS NULL + ) + -- 代理所有者 + OR p.owner_uuid = $2 + -- 团队成员可见的代理 + OR ( + $3 IS NOT NULL + AND EXISTS ( + SELECT 1 FROM proxy_visible_teams pvt + WHERE pvt.proxy_uuid = p.uuid + AND pvt.workspace_uuid = $1 + AND pvt.team_uuid = $3 + ) + ) + ) + ORDER BY p.created_at DESC + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 分页查询用户可见的代理列表,并支持名称搜索和筛选 +pub async fn fetch_visible_proxies_for_user_paginated( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Option, + name_keyword: Option<&str>, + proxy_type: Option<&str>, + status: Option<&str>, + country: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let name_keyword = name_keyword.map(|keyword| format!("%{}%", keyword.trim())); + + let recs = sqlx::query_as::<_, ProxyDto>( + r#" + SELECT DISTINCT p.id, p.uuid, p.workspace_uuid, p.owner_uuid, p.name, p.host, p.port, p.proxy_type, + p.username, p.password, p.ssh_key_encrypted, p.ssh_passphrase_encrypted, + p.country, p.city, p.status, p.latency, p.last_check_ip, p.last_checked_at, + (SELECT COUNT(*) FROM environments e WHERE e.proxy_uuid = p.uuid AND e.deleted_at IS NULL) AS environments_count, + p.created_at, p.updated_at, p.deleted_at + FROM proxies p + WHERE p.workspace_uuid = $1 + AND p.deleted_at IS NULL + AND ($4 IS NULL OR p.name LIKE $4) + AND ($5 IS NULL OR p.proxy_type = $5) + AND ($6 IS NULL OR p.status = $6) + AND ($7 IS NULL OR p.country = $7) + AND ( + EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.uuid = $1 AND w.owner_uuid = $2 AND w.deleted_at IS NULL + ) + OR p.owner_uuid = $2 + OR ( + $3 IS NOT NULL + AND EXISTS ( + SELECT 1 FROM proxy_visible_teams pvt + WHERE pvt.proxy_uuid = p.uuid + AND pvt.workspace_uuid = $1 + AND pvt.team_uuid = $3 + ) + ) + ) + ORDER BY p.created_at DESC + LIMIT $8 OFFSET $9 + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .bind(name_keyword) + .bind(proxy_type) + .bind(status) + .bind(country) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询用户可见代理总数,并支持名称搜索和筛选 +pub async fn fetch_visible_proxies_for_user_count( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, + team_uuid: Option, + name_keyword: Option<&str>, + proxy_type: Option<&str>, + status: Option<&str>, + country: Option<&str>, +) -> Result { + let name_keyword = name_keyword.map(|keyword| format!("%{}%", keyword.trim())); + + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(DISTINCT p.uuid) + FROM proxies p + WHERE p.workspace_uuid = $1 + AND p.deleted_at IS NULL + AND ($4 IS NULL OR p.name LIKE $4) + AND ($5 IS NULL OR p.proxy_type = $5) + AND ($6 IS NULL OR p.status = $6) + AND ($7 IS NULL OR p.country = $7) + AND ( + EXISTS ( + SELECT 1 FROM workspaces w + WHERE w.uuid = $1 AND w.owner_uuid = $2 AND w.deleted_at IS NULL + ) + OR p.owner_uuid = $2 + OR ( + $3 IS NOT NULL + AND EXISTS ( + SELECT 1 FROM proxy_visible_teams pvt + WHERE pvt.proxy_uuid = p.uuid + AND pvt.workspace_uuid = $1 + AND pvt.team_uuid = $3 + ) + ) + ) + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(team_uuid) + .bind(name_keyword) + .bind(proxy_type) + .bind(status) + .bind(country) + .fetch_one(pool) + .await?; + + Ok(count) +} diff --git a/src-tauri/crates/business/src/models/rpa.rs b/src-tauri/crates/business/src/models/rpa.rs new file mode 100644 index 00000000..7f7fa74b --- /dev/null +++ b/src-tauri/crates/business/src/models/rpa.rs @@ -0,0 +1,542 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool, placeholders}; + +use crate::dto::{RpaTaskDto, RpaTaskEnvironmentDto, RpaTaskRunDto, RpaTaskStepDto}; + +// ============ RPA Tasks ============ + +/// 创建 RPA 任务 +pub async fn insert_rpa_task( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Option, + name: &str, + description: Option<&str>, + tags: Option<&serde_json::Value>, + trigger_type: &str, + schedule: Option<&str>, + cron_expression: Option<&str>, + run_mode: &str, + retry_count: Option, + retry_interval: Option, + timeout: Option, + concurrency: Option, + stop_on_error: Option, + notify_on_complete: Option, + notify_on_error: Option, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO rpa_tasks (user_uuid, team_uuid, name, description, tags, trigger_type, + schedule, cron_expression, run_mode, retry_count, retry_interval, + timeout, concurrency, stop_on_error, notify_on_complete, notify_on_error) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + RETURNING uuid; + "#, + ) + .bind(user_uuid) + .bind(team_uuid) + .bind(name) + .bind(description) + .bind(tags) + .bind(trigger_type) + .bind(schedule) + .bind(cron_expression) + .bind(run_mode) + .bind(retry_count) + .bind(retry_interval) + .bind(timeout) + .bind(concurrency) + .bind(stop_on_error) + .bind(notify_on_complete) + .bind(notify_on_error) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询 RPA 任务列表 +pub async fn fetch_rpa_tasks( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + keyword: Option<&str>, + status: Option<&str>, + trigger_type: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, tags, trigger_type, + schedule, cron_expression, run_mode, retry_count, retry_interval, timeout, + concurrency, stop_on_error, notify_on_complete, notify_on_error, status, + run_count, success_count, last_run_at, next_run_at, created_at, updated_at, deleted_at + , (SELECT COUNT(*) FROM rpa_task_environments rte WHERE rte.task_uuid = rpa_tasks.uuid) AS environment_count + FROM rpa_tasks + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND ($3 IS NULL OR name LIKE $3 OR COALESCE(description, '') LIKE $3) + AND ($4 IS NULL OR status = $4) + AND ($5 IS NULL OR trigger_type = $5) + AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT $6 OFFSET $7 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(keyword.map(|k| format!("%{}%", k))) + .bind(status) + .bind(trigger_type) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询 RPA 任务总数 +pub async fn fetch_rpa_tasks_count( + pool: &Pool, + team_uuid: Option, + user_uuid: Uuid, + keyword: Option<&str>, + status: Option<&str>, + trigger_type: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM rpa_tasks + WHERE (team_uuid = $1 OR (team_uuid IS NULL AND user_uuid = $2)) + AND ($3 IS NULL OR status = $3) + AND ($4 IS NULL OR trigger_type = $4) + AND ($5 IS NULL OR name LIKE $5 OR COALESCE(description, '') LIKE $5) + AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(status) + .bind(trigger_type) + .bind(keyword.map(|k| format!("%{}%", k))) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询 RPA 任务 +pub async fn fetch_rpa_task_by_uuid( + pool: &Pool, + task_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, RpaTaskDto>( + r#" + SELECT id, uuid, user_uuid, team_uuid, name, description, tags, trigger_type, + schedule, cron_expression, run_mode, retry_count, retry_interval, timeout, + concurrency, stop_on_error, notify_on_complete, notify_on_error, status, + run_count, success_count, last_run_at, next_run_at, created_at, updated_at, deleted_at + , (SELECT COUNT(*) FROM rpa_task_environments rte WHERE rte.task_uuid = rpa_tasks.uuid) AS environment_count + FROM rpa_tasks + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(task_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新 RPA 任务 +pub async fn update_rpa_task( + pool: &Pool, + task_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + tags: Option<&serde_json::Value>, + trigger_type: Option<&str>, + schedule: Option<&str>, + cron_expression: Option<&str>, + run_mode: Option<&str>, + retry_count: Option, + retry_interval: Option, + timeout: Option, + concurrency: Option, + stop_on_error: Option, + notify_on_complete: Option, + notify_on_error: Option, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_tasks + SET name = COALESCE($1, name), + description = COALESCE($2, description), + tags = COALESCE($3, tags), + trigger_type = COALESCE($4, trigger_type), + schedule = COALESCE($5, schedule), + cron_expression = COALESCE($6, cron_expression), + run_mode = COALESCE($7, run_mode), + retry_count = COALESCE($8, retry_count), + retry_interval = COALESCE($9, retry_interval), + timeout = COALESCE($10, timeout), + concurrency = COALESCE($11, concurrency), + stop_on_error = COALESCE($12, stop_on_error), + notify_on_complete = COALESCE($13, notify_on_complete), + notify_on_error = COALESCE($14, notify_on_error), + updated_at = CURRENT_TIMESTAMP + WHERE uuid = $15 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(description) + .bind(tags) + .bind(trigger_type) + .bind(schedule) + .bind(cron_expression) + .bind(run_mode) + .bind(retry_count) + .bind(retry_interval) + .bind(timeout) + .bind(concurrency) + .bind(stop_on_error) + .bind(notify_on_complete) + .bind(notify_on_error) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新任务状态 +pub async fn update_rpa_task_status( + pool: &Pool, + task_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_tasks + SET status = $1, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 软删除 RPA 任务 +pub async fn delete_rpa_task(pool: &Pool, task_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_tasks SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 批量软删除 RPA 任务 +pub async fn batch_delete_rpa_tasks(pool: &Pool, task_uuids: &[Uuid]) -> Result { + if task_uuids.is_empty() { + return Ok(0); + } + + let statement = format!( + r#" + UPDATE rpa_tasks SET deleted_at = CURRENT_TIMESTAMP + WHERE uuid IN ({}) AND deleted_at IS NULL + "#, + placeholders(1, task_uuids.len()) + ); + let mut query = sqlx::query(&statement); + for uuid in task_uuids { + query = query.bind(uuid); + } + let result = query.execute(pool).await?; + + Ok(result.rows_affected()) +} + +// ============ RPA Task Steps ============ + +/// 插入任务步骤 +pub async fn insert_rpa_task_step( + pool: &Pool, + task_uuid: Uuid, + step_type: &str, + name: &str, + config: &serde_json::Value, + enabled: Option, + position_x: Option, + position_y: Option, + sort_order: Option, + next_step_uuid: Option, + branch_config: Option<&serde_json::Value>, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO rpa_task_steps (task_uuid, step_type, name, config, enabled, + position_x, position_y, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING uuid; + "#, + ) + .bind(task_uuid) + .bind(step_type) + .bind(name) + .bind(config) + .bind(enabled.unwrap_or(true)) + .bind(position_x) + .bind(position_y) + .bind(sort_order) + .bind(next_step_uuid) + .bind(branch_config) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询任务步骤列表 +pub async fn fetch_rpa_task_steps( + pool: &Pool, + task_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskStepDto>( + r#" + SELECT id, uuid, task_uuid, step_type, name, config, enabled, + position_x, position_y, sort_order, next_step_uuid, branch_config, + created_at, updated_at + FROM rpa_task_steps + WHERE task_uuid = $1 + ORDER BY sort_order ASC, id ASC + "#, + ) + .bind(task_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除任务所有步骤 +pub async fn delete_rpa_task_steps(pool: &Pool, task_uuid: Uuid) -> Result<(), Error> { + sqlx::query("DELETE FROM rpa_task_steps WHERE task_uuid = $1") + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ RPA Task Environments ============ + +/// 添加任务环境关联 +pub async fn insert_rpa_task_environment( + pool: &Pool, + task_uuid: Uuid, + environment_uuid: Uuid, + sort_order: Option, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO rpa_task_environments (task_uuid, environment_uuid, sort_order) + VALUES ($1, $2, $3) + RETURNING id; + "#, + ) + .bind(task_uuid) + .bind(environment_uuid) + .bind(sort_order) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询任务环境关联 +pub async fn fetch_rpa_task_environments( + pool: &Pool, + task_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskEnvironmentDto>( + r#" + SELECT id, task_uuid, environment_uuid, sort_order, created_at + FROM rpa_task_environments + WHERE task_uuid = $1 + ORDER BY sort_order ASC, id ASC + "#, + ) + .bind(task_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 删除任务所有环境关联 +pub async fn delete_rpa_task_environments(pool: &Pool, task_uuid: Uuid) -> Result<(), Error> { + sqlx::query("DELETE FROM rpa_task_environments WHERE task_uuid = $1") + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ RPA Task Runs ============ + +/// 创建任务执行记录 +pub async fn insert_rpa_task_run( + pool: &Pool, + task_uuid: Uuid, + total_steps: i32, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO rpa_task_runs (task_uuid, status, total_steps, completed_steps, failed_steps) + VALUES ($1, 'running', $2, 0, 0) + RETURNING uuid; + "#, + ) + .bind(task_uuid) + .bind(total_steps) + .fetch_one(pool) + .await?; + + // 更新任务最后运行时间和运行次数 + sqlx::query( + r#" + UPDATE rpa_tasks + SET last_run_at = CURRENT_TIMESTAMP, + run_count = COALESCE(run_count, 0) + 1 + WHERE uuid = $1 + "#, + ) + .bind(task_uuid) + .execute(pool) + .await?; + + Ok(uuid) +} + +/// 查询任务执行记录列表 +pub async fn fetch_rpa_task_runs( + pool: &Pool, + task_uuid: Uuid, + status: Option<&str>, + offset: i64, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, RpaTaskRunDto>( + r#" + SELECT id, uuid, task_uuid, status, total_steps, completed_steps, failed_steps, + started_at, finished_at, duration_ms, result_summary, error_message, logs + FROM rpa_task_runs + WHERE task_uuid = $1 + AND ($2 IS NULL OR status = $2) + ORDER BY started_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(task_uuid) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询任务执行记录总数 +pub async fn fetch_rpa_task_runs_count( + pool: &Pool, + task_uuid: Uuid, + status: Option<&str>, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM rpa_task_runs + WHERE task_uuid = $1 + AND ($2 IS NULL OR status = $2) + "#, + ) + .bind(task_uuid) + .bind(status) + .fetch_one(pool) + .await?; + + Ok(count) +} + +/// 根据 UUID 查询执行记录 +pub async fn fetch_rpa_task_run_by_uuid( + pool: &Pool, + run_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, RpaTaskRunDto>( + r#" + SELECT id, uuid, task_uuid, status, total_steps, completed_steps, failed_steps, + started_at, finished_at, duration_ms, result_summary, error_message, logs + FROM rpa_task_runs + WHERE uuid = $1 + "#, + ) + .bind(run_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新执行记录状态 +pub async fn update_rpa_task_run_status( + pool: &Pool, + run_uuid: Uuid, + status: &str, + completed_steps: i32, + failed_steps: i32, + result_summary: Option<&str>, + error_message: Option<&str>, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE rpa_task_runs + SET status = $1, + completed_steps = $2, + failed_steps = $3, + result_summary = $4, + error_message = $5, + finished_at = CASE WHEN $1 IN ('completed', 'failed', 'stopped') THEN CURRENT_TIMESTAMP ELSE finished_at END, + duration_ms = CASE WHEN $1 IN ('completed', 'failed', 'stopped') + THEN (julianday(CURRENT_TIMESTAMP) - julianday(started_at)) * 86400000 + ELSE duration_ms END + WHERE uuid = $6 + "#, + ) + .bind(status) + .bind(completed_steps) + .bind(failed_steps) + .bind(result_summary) + .bind(error_message) + .bind(run_uuid) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src-tauri/crates/business/src/models/teams.rs b/src-tauri/crates/business/src/models/teams.rs new file mode 100644 index 00000000..967dc092 --- /dev/null +++ b/src-tauri/crates/business/src/models/teams.rs @@ -0,0 +1,848 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool}; + +use crate::dto::{LoginHistoryDto, TeamDto, TeamInvitationDto, TeamMemberDto}; +use crate::entitys::CreateTeamRequest; + +// ============ Teams ============ + +/// 创建团队 +pub async fn insert_team( + pool: &Pool, + owner_uuid: Uuid, + payload: &CreateTeamRequest, +) -> Result { + let mut tx = pool.begin().await?; + + // 1. 创建团队 + let team_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO teams (workspace_uuid, name, description, owner_uuid) + VALUES ($1, $2, $3, $4) + RETURNING uuid; + "#, + ) + .bind(payload.workspace_uuid) + .bind(&payload.name) + .bind(&payload.description) + .bind(owner_uuid) + .fetch_one(&mut *tx) + .await?; + + // 2. 添加所有者为成员 + sqlx::query( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, status) + VALUES ($1, $2, $3, 'owner', 'active'); + "#, + ) + .bind(team_uuid) + .bind(payload.workspace_uuid) + .bind(owner_uuid) + .execute(&mut *tx) + .await?; + + // 3. 设置为用户当前团队 + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = $1 WHERE user_uuid = $2; + "#, + ) + .bind(team_uuid) + .bind(owner_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(team_uuid) +} + +/// 根据 UUID 查询团队 +pub async fn fetch_team_by_uuid( + pool: &Pool, + team_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TeamDto>( + r#" + SELECT id, uuid, workspace_uuid, name, description, owner_uuid, avatar_hash, + status, created_at, updated_at, deleted_at + FROM teams + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询用户在工作空间中所属的所有团队(工作空间级别) +pub async fn fetch_user_teams( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TeamDto>( + r#" + SELECT t.id, t.uuid, t.workspace_uuid, t.name, t.description, t.owner_uuid, t.avatar_hash, + t.status, t.created_at, t.updated_at, t.deleted_at + FROM teams t + INNER JOIN team_members tm ON t.uuid = tm.team_uuid + WHERE tm.workspace_uuid = $1 AND tm.user_uuid = $2 AND t.deleted_at IS NULL AND tm.deleted_at IS NULL + ORDER BY t.created_at + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 查询用户在本机加入的全部团队,不以当前工作空间为边界。 +pub async fn fetch_all_user_teams(pool: &Pool, user_uuid: Uuid) -> Result, Error> { + sqlx::query_as::<_, TeamDto>( + r#" + SELECT t.id, t.uuid, t.workspace_uuid, t.name, t.description, t.owner_uuid, t.avatar_hash, + t.status, t.created_at, t.updated_at, t.deleted_at + FROM teams t + INNER JOIN team_members tm ON t.uuid = tm.team_uuid + WHERE tm.user_uuid = $1 AND t.deleted_at IS NULL AND tm.deleted_at IS NULL + ORDER BY t.created_at + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await +} + +/// 查询用户当前团队 +pub async fn fetch_user_current_team( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec: Option = sqlx::query_scalar( + r#" + SELECT current_team_uuid FROM user_infos + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 设置用户当前团队 +pub async fn set_user_current_team( + pool: &Pool, + user_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = $1 WHERE user_uuid = $2 + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 清除用户当前团队 +pub async fn clear_user_current_team(pool: &Pool, user_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = NULL WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新团队信息 +pub async fn update_team( + pool: &Pool, + team_uuid: Uuid, + name: Option<&str>, + description: Option<&str>, + avatar_hash: Option<&str>, +) -> Result<(), Error> { + let mut query = String::from("UPDATE teams SET updated_at = CURRENT_TIMESTAMP"); + let mut params: Vec + Send + Sync>> = vec![]; + + if let Some(n) = name { + query.push_str(", name = $"); + params.push(Box::new(n)); + query.push_str(&format!("{}", params.len())); + } + + if let Some(d) = description { + query.push_str(", description = $"); + params.push(Box::new(d)); + query.push_str(&format!("{}", params.len())); + } + + if let Some(a) = avatar_hash { + query.push_str(", avatar_hash = $"); + params.push(Box::new(a)); + query.push_str(&format!("{}", params.len())); + } + + query.push_str(" WHERE uuid = $"); + params.push(Box::new(team_uuid)); + query.push_str(&format!("{}", params.len())); + + // 这里需要动态构建查询,但 sqlx 不支持动态查询,所以使用条件分支 + if name.is_some() && description.is_some() && avatar_hash.is_some() { + sqlx::query( + r#" + UPDATE teams + SET name = $1, description = $2, avatar_hash = $3, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $4 + "#, + ) + .bind(name.unwrap()) + .bind(description.unwrap()) + .bind(avatar_hash.unwrap()) + .bind(team_uuid) + .execute(pool) + .await?; + } else if name.is_some() && description.is_some() { + sqlx::query( + r#" + UPDATE teams + SET name = $1, description = $2, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $3 + "#, + ) + .bind(name.unwrap()) + .bind(description.unwrap()) + .bind(team_uuid) + .execute(pool) + .await?; + } else if name.is_some() { + sqlx::query( + r#" + UPDATE teams + SET name = $1, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $2 + "#, + ) + .bind(name.unwrap()) + .bind(team_uuid) + .execute(pool) + .await?; + } + + Ok(()) +} + +// ============ Team Members ============ + +/// 获取团队成员数量 +pub async fn fetch_team_member_count( + pool: &Pool, + team_uuid: Uuid, + keyword: Option<&str>, + role: Option<&str>, + status: Option<&str>, +) -> Result { + let mut query = String::from( + r#" + SELECT COUNT(*) FROM team_members tm + LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid + WHERE tm.team_uuid = $1 AND tm.deleted_at IS NULL + "#, + ); + + let mut param_index = 2; + + // 默认只查询 active 状态 + if status.is_some() { + query.push_str(&format!(" AND tm.status = ${}", param_index)); + param_index += 1; + } else { + query.push_str(" AND tm.status = 'active'"); + } + + if role.is_some() { + query.push_str(&format!(" AND tm.role = ${}", param_index)); + param_index += 1; + } + + if keyword.is_some() { + query.push_str(&format!( + " AND (ui.nickname LIKE ${} OR ui.email LIKE ${})", + param_index, param_index + )); + } + + let mut query_builder = sqlx::query_scalar::<_, i64>(&query).bind(team_uuid); + + if let Some(s) = status { + query_builder = query_builder.bind(s); + } + + if let Some(r) = role { + query_builder = query_builder.bind(r); + } + + if let Some(k) = keyword { + let keyword_pattern = format!("%{}%", k); + query_builder = query_builder.bind(keyword_pattern); + } + + let count = query_builder.fetch_one(pool).await?; + + Ok(count) +} + +/// 查询团队成员列表(关联用户信息,支持筛选) +pub async fn fetch_team_members( + pool: &Pool, + team_uuid: Uuid, + offset: i64, + limit: i64, + keyword: Option<&str>, + role: Option<&str>, + status: Option<&str>, +) -> Result, Error> { + let mut query = String::from( + r#" + SELECT + tm.id, + tm.team_uuid, + tm.workspace_uuid, + tm.user_uuid, + tm.role, + tm.joined_at, + tm.invited_by, + tm.status, + tm.created_at, + tm.updated_at, + tm.deleted_at, + ui.nickname AS name, + ui.email AS email, + ui.avatar_hash AS avatar + FROM team_members tm + LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid + WHERE tm.team_uuid = $1 AND tm.deleted_at IS NULL + "#, + ); + + let mut param_index = 2; + + // 默认只查询 active 状态 + if status.is_some() { + query.push_str(&format!(" AND tm.status = ${}", param_index)); + param_index += 1; + } else { + query.push_str(" AND tm.status = 'active'"); + } + + if role.is_some() { + query.push_str(&format!(" AND tm.role = ${}", param_index)); + param_index += 1; + } + + if keyword.is_some() { + query.push_str(&format!( + " AND (ui.nickname LIKE ${} OR ui.email LIKE ${})", + param_index, param_index + )); + param_index += 1; + } + + query.push_str(&format!( + r#" + ORDER BY + CASE tm.role + WHEN 'owner' THEN 1 + WHEN 'admin' THEN 2 + WHEN 'editor' THEN 3 + ELSE 4 + END, + tm.joined_at + LIMIT ${} OFFSET ${} + "#, + param_index, + param_index + 1 + )); + + let mut query_builder = sqlx::query_as::<_, TeamMemberDto>(&query).bind(team_uuid); + + if let Some(s) = status { + query_builder = query_builder.bind(s); + } + + if let Some(r) = role { + query_builder = query_builder.bind(r); + } + + if let Some(k) = keyword { + let keyword_pattern = format!("%{}%", k); + query_builder = query_builder.bind(keyword_pattern); + } + + query_builder = query_builder.bind(limit).bind(offset); + + let recs = query_builder.fetch_all(pool).await?; + + Ok(recs) +} + +/// 查询用户在团队中的成员信息(工作空间级别) +pub async fn fetch_team_member( + pool: &Pool, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TeamMemberDto>( + r#" + SELECT + tm.id, + tm.team_uuid, + tm.workspace_uuid, + tm.user_uuid, + tm.role, + tm.joined_at, + tm.invited_by, + tm.status, + tm.created_at, + tm.updated_at, + tm.deleted_at, + ui.nickname AS name, + ui.email AS email, + ui.avatar_hash AS avatar + FROM team_members tm + LEFT JOIN user_infos ui ON tm.user_uuid = ui.user_uuid + WHERE tm.workspace_uuid = $1 AND tm.team_uuid = $2 AND tm.user_uuid = $3 AND tm.deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 添加团队成员 +pub async fn insert_team_member( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, + role: &str, + invited_by: Option, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, invited_by, status) + VALUES ($1, (SELECT workspace_uuid FROM teams WHERE uuid = $1), $2, $3, $4, 'active') + RETURNING id; + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .bind(role) + .bind(invited_by) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 更新成员角色 +pub async fn update_member_role( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, + role: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_members + SET role = $1, updated_at = CURRENT_TIMESTAMP + WHERE team_uuid = $2 AND user_uuid = $3 AND deleted_at IS NULL + "#, + ) + .bind(role) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新成员状态 +pub async fn update_member_status( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_members + SET status = $1 + WHERE team_uuid = $2 AND user_uuid = $3 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 移除成员(软删除) +pub async fn remove_team_member( + pool: &Pool, + team_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_members + SET deleted_at = CURRENT_TIMESTAMP, status = 'inactive' + WHERE team_uuid = $1 AND user_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +// ============ Team Invitations ============ + +/// 创建团队邀请 +pub async fn insert_team_invitation( + pool: &Pool, + team_uuid: Uuid, + email: &str, + role: &str, + invited_by: Uuid, + token: &str, + expires_at: chrono::DateTime, +) -> Result { + let uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO team_invitations (team_uuid, email, role, invited_by, token, expires_at, status) + VALUES ($1, $2, $3, $4, $5, $6, 'pending') + RETURNING uuid; + "#, + ) + .bind(team_uuid) + .bind(email) + .bind(role) + .bind(invited_by) + .bind(token) + .bind(expires_at) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 查询团队邀请 +pub async fn fetch_team_invitation_by_token( + pool: &Pool, + token: &str, +) -> Result, Error> { + let rec = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE token = $1 AND deleted_at IS NULL + "#, + ) + .bind(token) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询团队的待处理邀请 +pub async fn fetch_pending_invitations( + pool: &Pool, + team_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE team_uuid = $1 AND status = 'pending' AND deleted_at IS NULL + ORDER BY created_at DESC + "#, + ) + .bind(team_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 检查是否有待处理的邀请 +pub async fn has_pending_invitation( + pool: &Pool, + team_uuid: Uuid, + email: &str, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM team_invitations + WHERE team_uuid = $1 AND email = $2 AND status = 'pending' AND deleted_at IS NULL + "#, + ) + .bind(team_uuid) + .bind(email) + .fetch_one(pool) + .await?; + + Ok(count > 0) +} + +/// 更新邀请状态 +pub async fn update_invitation_status( + pool: &Pool, + invitation_uuid: Uuid, + status: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_invitations + SET status = $1, updated_at = CURRENT_TIMESTAMP + WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(status) + .bind(invitation_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询邀请(通过 token,别名函数) +pub async fn fetch_invitation_by_token( + pool: &Pool, + token: &str, +) -> Result, Error> { + fetch_team_invitation_by_token(pool, token).await +} + +/// 取消邀请 +pub async fn cancel_team_invitation(pool: &Pool, invitation_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE team_invitations + SET status = 'cancelled', deleted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(invitation_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 接受邀请 +pub async fn accept_team_invitation( + pool: &Pool, + invitation_uuid: Uuid, + user_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result { + let mut tx = pool.begin().await?; + + // 1. 获取邀请信息 + let invitation = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(invitation_uuid) + .fetch_optional(&mut *tx) + .await?; + + let invitation = invitation.ok_or_else(|| Error::RowNotFound)?; + + // 2. 检查邀请状态 + if invitation.status != "pending" { + return Err(Error::RowNotFound); + } + + // 3. 检查是否过期 + if invitation.expires_at < chrono::Utc::now() { + return Err(Error::RowNotFound); + } + + // 4. 添加成员(如果已存在则更新状态为 active) + sqlx::query( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, invited_by, status) + VALUES ($1, $2, $3, $4, $5, 'active') + ON CONFLICT (team_uuid, user_uuid, workspace_uuid) DO UPDATE SET + role = EXCLUDED.role, + invited_by = EXCLUDED.invited_by, + status = 'active', + deleted_at = NULL, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(invitation.team_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .bind(&invitation.role) + .bind(invitation.invited_by) + .execute(&mut *tx) + .await?; + + // 5. 更新邀请状态 + sqlx::query( + r#" + UPDATE team_invitations + SET status = 'accepted', accepted_at = CURRENT_TIMESTAMP + WHERE uuid = $1 + "#, + ) + .bind(invitation_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(invitation.team_uuid) +} + +/// 拒绝邀请 +pub async fn reject_team_invitation( + pool: &Pool, + invitation_uuid: Uuid, + _user_uuid: Uuid, +) -> Result<(), Error> { + let mut tx = pool.begin().await?; + + // 1. 获取邀请信息 + let invitation = sqlx::query_as::<_, TeamInvitationDto>( + r#" + SELECT id, uuid, team_uuid, email, role, invited_by, token, expires_at, status, accepted_at, created_at, updated_at + FROM team_invitations + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(invitation_uuid) + .fetch_optional(&mut *tx) + .await?; + + let invitation = invitation.ok_or_else(|| Error::RowNotFound)?; + + // 2. 检查邀请状态(只有 pending 状态的邀请才能被拒绝) + if invitation.status != "pending" { + return Err(Error::RowNotFound); + } + + // 3. 检查是否过期(过期的邀请也可以标记为拒绝) + // 这里不检查过期时间,允许拒绝已过期的邀请 + + // 4. 更新邀请状态为 rejected + sqlx::query( + r#" + UPDATE team_invitations + SET status = 'rejected', updated_at = CURRENT_TIMESTAMP + WHERE uuid = $1 + "#, + ) + .bind(invitation_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) +} + +// ============ Login History ============ + +/// 记录登录历史 +pub async fn insert_login_history( + pool: &Pool, + user_uuid: Uuid, + ip_address: &str, + device_info: Option<&str>, + user_agent: Option<&str>, + location: Option<&str>, + country: Option<&str>, + city: Option<&str>, + success: bool, + failure_reason: Option<&str>, +) -> Result { + let id: i64 = sqlx::query_scalar( + r#" + INSERT INTO login_history (user_uuid, ip_address, device_info, user_agent, location, country, city, success, failure_reason) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(ip_address) + .bind(device_info) + .bind(user_agent) + .bind(location) + .bind(country) + .bind(city) + .bind(success) + .bind(failure_reason) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 查询用户登录历史 +pub async fn fetch_user_login_history( + pool: &Pool, + user_uuid: Uuid, + limit: i64, +) -> Result, Error> { + let recs = sqlx::query_as::<_, LoginHistoryDto>( + r#" + SELECT id, user_uuid, ip_address, device_info, user_agent, location, country, city, success, failure_reason, created_at + FROM login_history + WHERE user_uuid = $1 + ORDER BY created_at DESC + LIMIT $2 + "#, + ) + .bind(user_uuid) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(recs) +} diff --git a/src-tauri/crates/business/src/models/user.rs b/src-tauri/crates/business/src/models/user.rs new file mode 100644 index 00000000..efef2ae5 --- /dev/null +++ b/src-tauri/crates/business/src/models/user.rs @@ -0,0 +1,405 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool}; + +use crate::dto::{LocalApiPermissionDefinitionDto, UserDto, UserInfoDto}; +use crate::entitys::{RegisterRequest, UpdateUserRequest}; + +/// 插入用户基础信息 +pub async fn insert_user(pool: &Pool, user_id: String) -> Result { + let uuid: Uuid = sqlx::query_scalar("INSERT INTO users (id) VALUES ($1) RETURNING uuid;") + .bind(user_id) + .fetch_one(pool) + .await?; + + Ok(uuid) +} + +/// 插入用户详细信息 +pub async fn insert_user_info( + pool: &Pool, + user_uuid: Uuid, + payload: &RegisterRequest, + password_hash: &str, +) -> Result { + let id: i32 = sqlx::query_scalar( + r#" + INSERT INTO user_infos (user_uuid, email, password, nickname, status) + VALUES ($1, $2, $3, $4, 'active') + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(&payload.email) + .bind(password_hash) + .bind(&payload.nickname) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// 使用事务创建用户(users + user_infos + 初始化数据) +/// +/// 初始化数据包括: +/// - user_preferences: 用户偏好设置 +/// - teams: 个人团队(每个用户自动创建一个团队) +pub async fn create_user_with_info( + pool: &Pool, + user_id: String, + payload: &RegisterRequest, + password_hash: &str, + quota: &crate::utils::WorkspaceQuotaValues, +) -> Result { + let local_api_permission_definitions = + crate::models::local_api::fetch_permission_definitions(pool).await?; + + let mut tx = pool.begin().await?; + + // 1. 创建用户基础记录 + let user_uuid: Uuid = sqlx::query_scalar("INSERT INTO users (id) VALUES ($1) RETURNING uuid;") + .bind(&user_id) + .fetch_one(&mut *tx) + .await?; + + // 2. 创建用户详细信息 + sqlx::query( + r#" + INSERT INTO user_infos (user_uuid, email, password, nickname, status) + VALUES ($1, $2, $3, $4, 'active'); + "#, + ) + .bind(user_uuid) + .bind(&payload.email) + .bind(password_hash) + .bind(&payload.nickname) + .execute(&mut *tx) + .await?; + + // 3. 初始化用户偏好设置 + sqlx::query( + r#" + INSERT INTO user_preferences (user_uuid, theme, language, notifications_enabled) + VALUES ($1, 'system', 'zh-CN', TRUE); + "#, + ) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 4. 初始化本地 API 配置 + sqlx::query( + r#" + INSERT INTO user_local_api_settings (user_uuid, enabled, port, remote_access, cors_origins) + VALUES ($1, FALSE, 8080, FALSE, '[]'); + "#, + ) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 5. 初始化本地 API 密钥 + let local_api_key = generate_local_api_key(); + let local_api_key_hash = crate::models::local_api::hash_api_key(&local_api_key); + let local_api_key_prefix = local_api_key.chars().take(16).collect::(); + let local_api_key_id: i32 = sqlx::query_scalar( + r#" + INSERT INTO user_local_api_keys (user_uuid, key_prefix, key_hash, api_key, daily_limit) + VALUES ($1, $2, $3, $4, 1000) + RETURNING id; + "#, + ) + .bind(user_uuid) + .bind(&local_api_key_prefix) + .bind(&local_api_key_hash) + .bind(&local_api_key) + .fetch_one(&mut *tx) + .await?; + + // 6. 初始化本地 API 权限记录 + insert_local_api_permissions(&mut tx, local_api_key_id, &local_api_permission_definitions) + .await?; + + // 7. 创建个人团队(每个用户都应该有一个团队) + // 团队名称使用用户昵称,如果没有昵称则使用邮箱前缀 + let team_name = + payload.nickname.as_ref().map(|n| format!("{} 的团队", n)).unwrap_or_else(|| { + format!( + "{} 的团队", + payload.email.split('@').next().unwrap_or("用户") + ) + }); + + // 8. 创建个人工作空间 + let workspace_name = format!( + "{} 的工作空间", + payload.nickname.as_deref().unwrap_or("用户") + ); + let workspace_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO workspaces (name, owner_uuid, workspace_type) + VALUES ($1, $2, 'personal') + RETURNING uuid; + "#, + ) + .bind(&workspace_name) + .bind(user_uuid) + .fetch_one(&mut *tx) + .await?; + + // 9. 创建工作空间配额(使用传入的配额配置) + sqlx::query( + r#" + INSERT INTO workspace_quotas (workspace_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks) + VALUES ($1, $2, $3, $4, $5); + "#, + ) + .bind(workspace_uuid) + .bind(quota.max_environments) + .bind(quota.max_team_members) + .bind(quota.max_proxies) + .bind(quota.max_rpa_tasks) + .execute(&mut *tx) + .await?; + + // 10. 创建个人团队(每个用户自动创建一个团队) + let team_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO teams (workspace_uuid, name, description, owner_uuid) + VALUES ($1, $2, $3, $4) + RETURNING uuid; + "#, + ) + .bind(workspace_uuid) + .bind(&team_name) + .bind(Some("个人团队")) + .bind(user_uuid) + .fetch_one(&mut *tx) + .await?; + + // 11. 添加用户为团队成员(owner 角色) + sqlx::query( + r#" + INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, status) + VALUES ($1, $2, $3, 'owner', 'active'); + "#, + ) + .bind(team_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + // 12. 设置为用户当前团队和工作空间 + sqlx::query( + r#" + UPDATE user_infos SET current_team_uuid = $1, current_workspace_uuid = $2 WHERE user_uuid = $3; + "#, + ) + .bind(team_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(user_uuid) +} + +async fn insert_local_api_permissions( + tx: &mut sqlx::Transaction<'_, Db>, + api_key_id: i32, + definitions: &[LocalApiPermissionDefinitionDto], +) -> Result<(), Error> { + for definition in definitions { + sqlx::query( + r#" + INSERT INTO user_local_api_key_permissions ( + api_key_id, permission_code, is_enabled, rate_limit_per_minute, rate_limit_per_hour + ) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (api_key_id, permission_code) DO NOTHING; + "#, + ) + .bind(api_key_id) + .bind(definition.permission_code.as_str()) + .bind(definition.default_enabled) + .bind(definition.default_rate_limit_per_minute) + .bind(definition.default_rate_limit_per_hour) + .execute(&mut **tx) + .await?; + } + + Ok(()) +} + +fn generate_local_api_key() -> String { + let raw = Uuid::new_v4().simple().to_string(); + format!("sk_local_{}", raw) +} + +/// 根据 UUID 查询用户 +pub async fn fetch_user_by_uuid( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserDto>( + r#" + SELECT uuid, id, created_at, updated_at, deleted_at + FROM users + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询用户详细信息 +pub async fn fetch_user_info_by_uuid( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserInfoDto>( + r#" + SELECT id, user_uuid, nickname, email, phone, password, avatar_hash, status, + current_team_uuid, current_workspace_uuid, created_at, updated_at, deleted_at + FROM user_infos + WHERE user_uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 根据邮箱查询用户详细信息 +pub async fn fetch_user_info_by_email( + pool: &Pool, + email: &str, +) -> Result, Error> { + let rec = sqlx::query_as::<_, UserInfoDto>( + r#" + SELECT id, user_uuid, nickname, email, phone, password, avatar_hash, status, + current_team_uuid, current_workspace_uuid, created_at, updated_at, deleted_at + FROM user_infos + WHERE email = $1 AND deleted_at IS NULL + "#, + ) + .bind(email) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 更新用户信息 +pub async fn update_user_info( + pool: &Pool, + user_uuid: Uuid, + payload: &UpdateUserRequest, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos + SET nickname = COALESCE($1, nickname), + phone = COALESCE($2, phone), + email = COALESCE($3, email), + updated_at = CURRENT_TIMESTAMP + WHERE user_uuid = $4 AND deleted_at IS NULL + "#, + ) + .bind(payload.nickname.as_deref()) + .bind(payload.phone.as_deref()) + .bind(payload.email.as_deref()) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新密码 +pub async fn update_password( + pool: &Pool, + user_uuid: Uuid, + password_hash: &str, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos + SET password = $1 + WHERE user_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(password_hash) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 设置用户当前工作空间 +pub async fn fetch_user_current_workspace( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let rec: Option = sqlx::query_scalar( + r#" + SELECT current_workspace_uuid FROM user_infos + WHERE user_uuid = $1 + "#, + ) + .bind(user_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +pub async fn set_user_current_workspace( + pool: &Pool, + user_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos SET current_workspace_uuid = $1 WHERE user_uuid = $2 + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn set_user_current_workspace_and_team( + pool: &Pool, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE user_infos + SET current_workspace_uuid = $1, current_team_uuid = $2 + WHERE user_uuid = $3 + "#, + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(user_uuid) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src-tauri/crates/business/src/models/workspace_quotas.rs b/src-tauri/crates/business/src/models/workspace_quotas.rs new file mode 100644 index 00000000..1a1bbe67 --- /dev/null +++ b/src-tauri/crates/business/src/models/workspace_quotas.rs @@ -0,0 +1,232 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool}; + +use crate::dto::WorkspaceQuotaDto; + +/// 创建或更新工作空间配额 +pub async fn insert_or_update_workspace_quota( + pool: &Pool, + workspace_uuid: Uuid, + max_environments: i32, + max_team_members: i32, + max_proxies: i32, + max_rpa_tasks: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + INSERT INTO workspace_quotas ( + workspace_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks + ) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (workspace_uuid) DO UPDATE SET + max_environments = EXCLUDED.max_environments, + max_team_members = EXCLUDED.max_team_members, + max_proxies = EXCLUDED.max_proxies, + max_rpa_tasks = EXCLUDED.max_rpa_tasks, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(workspace_uuid) + .bind(max_environments) + .bind(max_team_members) + .bind(max_proxies) + .bind(max_rpa_tasks) + .execute(pool) + .await?; + + Ok(()) +} + +/// 查询工作空间配额 +pub async fn fetch_workspace_quota( + pool: &Pool, + workspace_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, WorkspaceQuotaDto>( + r#" + SELECT workspace_uuid, max_environments, used_environments, + max_team_members, used_team_members, + max_proxies, used_proxies, + max_rpa_tasks, used_rpa_tasks, + created_at, updated_at + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 增加环境使用数 +pub async fn increment_used_environments( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_environments = used_environments + $1, + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 减少环境使用数 +pub async fn decrement_used_environments( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_environments = GREATEST(0, used_environments - $1), + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 增加代理使用数 +pub async fn increment_used_proxies( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_proxies = used_proxies + $1, + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 减少代理使用数 +pub async fn decrement_used_proxies( + pool: &Pool, + workspace_uuid: Uuid, + amount: i32, +) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas + SET used_proxies = GREATEST(0, used_proxies - $1), + updated_at = CURRENT_TIMESTAMP + WHERE workspace_uuid = $2 + "#, + ) + .bind(amount) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 更新团队成员使用数(统计所有团队的活跃成员) +pub async fn update_used_team_members(pool: &Pool, workspace_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspace_quotas wq + SET used_team_members = ( + SELECT COUNT(DISTINCT tm.user_uuid) + FROM team_members tm + INNER JOIN teams t ON tm.team_uuid = t.uuid + WHERE t.workspace_uuid = wq.workspace_uuid + AND tm.deleted_at IS NULL + AND t.deleted_at IS NULL + ), + updated_at = CURRENT_TIMESTAMP + WHERE wq.workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 检查配额是否充足 +pub async fn check_quota( + pool: &Pool, + workspace_uuid: Uuid, + quota_type: &str, +) -> Result { + let result = match quota_type { + "environments" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_environments < max_environments + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + "proxies" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_proxies < max_proxies + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + "team_members" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_team_members < max_team_members + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + "rpa_tasks" => sqlx::query_scalar::<_, bool>( + r#" + SELECT used_rpa_tasks < max_rpa_tasks + FROM workspace_quotas + WHERE workspace_uuid = $1 + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await? + .unwrap_or(false), + _ => false, + }; + + Ok(result) +} diff --git a/src-tauri/crates/business/src/models/workspaces.rs b/src-tauri/crates/business/src/models/workspaces.rs new file mode 100644 index 00000000..608fe07f --- /dev/null +++ b/src-tauri/crates/business/src/models/workspaces.rs @@ -0,0 +1,123 @@ +use sqlx::Error; +use uuid::Uuid; + +use crate::database::{Db, Pool}; + +use crate::dto::WorkspaceDto; +use crate::entitys::CreateWorkspaceRequest; + +/// 创建工作空间 +pub async fn insert_workspace( + pool: &Pool, + owner_uuid: Uuid, + payload: &CreateWorkspaceRequest, +) -> Result { + let workspace_uuid: Uuid = sqlx::query_scalar( + r#" + INSERT INTO workspaces (name, owner_uuid, workspace_type) + VALUES ($1, $2, $3) + RETURNING uuid; + "#, + ) + .bind(&payload.name) + .bind(owner_uuid) + .bind(payload.workspace_type.as_deref().unwrap_or("personal")) + .fetch_one(pool) + .await?; + + Ok(workspace_uuid) +} + +/// 根据 UUID 查询工作空间 +pub async fn fetch_workspace_by_uuid( + pool: &Pool, + workspace_uuid: Uuid, +) -> Result, Error> { + let rec = sqlx::query_as::<_, WorkspaceDto>( + r#" + SELECT uuid, name, owner_uuid, workspace_type, created_at, updated_at, deleted_at + FROM workspaces + WHERE uuid = $1 AND deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .fetch_optional(pool) + .await?; + + Ok(rec) +} + +/// 查询用户所属的所有工作空间 +pub async fn fetch_user_workspaces( + pool: &Pool, + user_uuid: Uuid, +) -> Result, Error> { + let recs = sqlx::query_as::<_, WorkspaceDto>( + r#" + SELECT uuid, name, owner_uuid, workspace_type, created_at, updated_at, deleted_at + FROM workspaces + WHERE owner_uuid = $1 AND deleted_at IS NULL + ORDER BY created_at + "#, + ) + .bind(user_uuid) + .fetch_all(pool) + .await?; + + Ok(recs) +} + +/// 更新工作空间 +pub async fn update_workspace( + pool: &Pool, + workspace_uuid: Uuid, + name: Option<&str>, +) -> Result<(), Error> { + if let Some(name) = name { + sqlx::query( + r#" + UPDATE workspaces SET name = $1 WHERE uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(name) + .bind(workspace_uuid) + .execute(pool) + .await?; + } + + Ok(()) +} + +/// 删除工作空间(软删除) +pub async fn delete_workspace(pool: &Pool, workspace_uuid: Uuid) -> Result<(), Error> { + sqlx::query( + r#" + UPDATE workspaces SET deleted_at = CURRENT_TIMESTAMP WHERE uuid = $1 + "#, + ) + .bind(workspace_uuid) + .execute(pool) + .await?; + + Ok(()) +} + +/// 检查用户是否是工作空间所有者 +pub async fn check_workspace_owner( + pool: &Pool, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM workspaces + WHERE uuid = $1 AND owner_uuid = $2 AND deleted_at IS NULL + "#, + ) + .bind(workspace_uuid) + .bind(user_uuid) + .fetch_one(pool) + .await?; + + Ok(count > 0) +} diff --git a/src-tauri/crates/business/src/services.rs b/src-tauri/crates/business/src/services.rs new file mode 100644 index 00000000..0ecf6ac8 --- /dev/null +++ b/src-tauri/crates/business/src/services.rs @@ -0,0 +1,38 @@ +mod time; + +// 新增模块 +pub mod accounts; +pub mod audit; +pub mod browser_kernels; +pub mod environments; +pub mod group_permissions; +pub mod groups; +pub mod local_api; +pub mod local_users; +pub mod messages; +pub mod preferences; +pub mod proxies; +pub mod proxy_visibility; +pub mod rpa; +pub mod tags; +pub mod teams; +pub mod templates; +pub mod workspace_quotas; +pub mod workspaces; + +pub use time::*; + +// 新增导出 +pub use accounts::*; +pub use audit::*; +pub use environments::*; +pub use group_permissions::*; +pub use groups::*; +pub use messages::*; +pub use proxies::*; +pub use proxy_visibility::*; +pub use tags::*; +pub use teams::*; +pub use templates::*; +pub use workspace_quotas::*; +pub use workspaces::*; diff --git a/src-tauri/crates/business/src/services/accounts.rs b/src-tauri/crates/business/src/services/accounts.rs new file mode 100644 index 00000000..a552bb3f --- /dev/null +++ b/src-tauri/crates/business/src/services/accounts.rs @@ -0,0 +1,210 @@ +use uuid::Uuid; + +use crate::dto::PlatformAccountDto; +use crate::entitys::{ + BatchImportAccountsRequest, CreateAccountRequest, ListAccountsRequest, UpdateAccountRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建账号 +pub async fn create_account_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateAccountRequest, +) -> Result { + models::insert_platform_account( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.platform_url, + payload.platform_name.as_deref(), + &payload.account, + payload.password.as_deref(), + payload.remark.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取账号列表 +pub async fn get_accounts_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &ListAccountsRequest, +) -> Result<(Vec, i64), String> { + let page = payload.pagination.page.max(1); + let page_size = payload.pagination.page_size.max(1); + let offset = (page - 1) * page_size; + + let keyword = payload + .filters + .as_ref() + .and_then(|f| f.keyword.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let platform_name = payload + .filters + .as_ref() + .and_then(|f| f.platform_name.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let status = payload + .filters + .as_ref() + .and_then(|f| f.status.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + + let accounts = models::fetch_platform_accounts( + &svc_ctx.db, + team_uuid, + user_uuid, + keyword, + platform_name, + status, + offset, + page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_platform_accounts_count( + &svc_ctx.db, + team_uuid, + user_uuid, + keyword, + platform_name, + status, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((accounts, total)) +} + +/// 获取账号详情 +pub async fn get_account_service( + svc_ctx: &SvcCtx, + account_uuid: Uuid, +) -> Result { + models::fetch_platform_account_by_uuid(&svc_ctx.db, account_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "账号不存在".to_string()) +} + +/// 更新账号 +pub async fn update_account_service( + svc_ctx: &SvcCtx, + payload: &UpdateAccountRequest, +) -> Result<(), String> { + models::update_platform_account( + &svc_ctx.db, + payload.uuid, + payload.platform_url.as_deref(), + payload.platform_name.as_deref(), + payload.account.as_deref(), + payload.password.as_deref(), + payload.remark.as_deref(), + payload.status.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除账号 +pub async fn delete_account_service(svc_ctx: &SvcCtx, account_uuid: Uuid) -> Result<(), String> { + models::delete_platform_account(&svc_ctx.db, account_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量删除账号 +pub async fn batch_delete_accounts_service( + svc_ctx: &SvcCtx, + account_uuids: &[Uuid], +) -> Result { + models::batch_delete_platform_accounts(&svc_ctx.db, account_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境关联的账号 +pub async fn get_environment_accounts_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_accounts(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 设置环境关联的账号 +pub async fn set_environment_accounts_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + account_uuids: &[Uuid], +) -> Result<(), String> { + // 清空现有关联 + models::accounts::clear_environment_accounts(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + // 添加新关联 + for (idx, account_uuid) in account_uuids.iter().enumerate() { + models::accounts::insert_environment_account( + &svc_ctx.db, + env_uuid, + *account_uuid, + idx as i32, + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +/// 批量导入账号 +/// +/// 接收客户端已解析好的账号列表,直接保存到数据库 +pub async fn batch_import_accounts_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &BatchImportAccountsRequest, +) -> Result { + let mut success_count = 0; + let mut failed_count = 0; + let mut errors: Vec = vec![]; + for (index, account) in payload.accounts.iter().enumerate() { + let result = models::insert_platform_account( + &svc_ctx.db, + user_uuid, + team_uuid, + &account.platform_url, + account.platform_name.as_deref(), + &account.account, + account.password.as_deref(), + account.remark.as_deref(), + ) + .await; + + match result { + Ok(_) => success_count += 1, + Err(e) => { + failed_count += 1; + errors.push(format!("第 {} 项: {}", index + 1, e)); + } + } + } + + Ok(crate::entitys::BatchImportResponse { + success_count, + failed_count, + errors, + }) +} diff --git a/src-tauri/crates/business/src/services/audit.rs b/src-tauri/crates/business/src/services/audit.rs new file mode 100644 index 00000000..7c960fbc --- /dev/null +++ b/src-tauri/crates/business/src/services/audit.rs @@ -0,0 +1,351 @@ +use chrono::Datelike; +use uuid::Uuid; + +use crate::dto::AuditLogDto; +use crate::entitys::{ + ActionCount, AuditStatsResponse, ExportAuditLogsRequest, ListAuditLogsRequest, TargetTypeCount, +}; +use crate::models; +use crate::state::RequestContext; +use crate::svc_ctx::SvcCtx; + +/// 审计日志宏 - 简化审计日志记录 +/// +/// # 用法 +/// ```ignore +/// // 基础用法: action, target_type, detail +/// audit_log!(svc_ctx, ctx, "login", "user", "用户登录"); +/// +/// // 带目标 UUID +/// audit_log!(svc_ctx, ctx, "delete", "environment", env_uuid, "删除环境"); +/// +/// // 带目标 UUID 和名称 +/// audit_log!(svc_ctx, ctx, "delete", "environment", env_uuid, "生产环境", "删除环境"); +/// ``` +#[macro_export] +macro_rules! audit_log { + // 基础: action, target_type, detail + ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $detail:expr) => { + $crate::services::audit::log_audit( + $svc_ctx, + $ctx, + $action, + $target_type, + None, + None, + $detail, + ) + }; + // 带目标 UUID: action, target_type, target_uuid, detail + ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $target_uuid:expr, $detail:expr) => { + $crate::services::audit::log_audit( + $svc_ctx, + $ctx, + $action, + $target_type, + Some($target_uuid), + None, + $detail, + ) + }; + // 完整: action, target_type, target_uuid, target_name, detail + ($svc_ctx:expr, $ctx:expr, $action:expr, $target_type:expr, $target_uuid:expr, $target_name:expr, $detail:expr) => { + $crate::services::audit::log_audit( + $svc_ctx, + $ctx, + $action, + $target_type, + Some($target_uuid), + Some($target_name), + $detail, + ) + }; +} + +/// 记录审计日志(宏的内部实现) +/// +/// 自动从 RequestContext 中提取 user_uuid、team_uuid、ip_address +pub async fn log_audit( + svc_ctx: &SvcCtx, + ctx: &RequestContext, + action: &str, + target_type: &str, + target_uuid: Option, + target_name: Option<&str>, + detail: &str, +) { + // 从 ctx 中提取数据 + let user_uuid = match ctx.user_uuid() { + Some(uuid) => uuid, + None => { + tracing::warn!("audit_log: user_uuid is None, skipping audit log"); + return; + } + }; + + let team_uuid = ctx.current_team_uuid; + let ip_address = ctx.ip(); + + // 异步记录,忽略错误(审计失败不应影响业务) + if let Err(e) = log_action_service( + svc_ctx, + user_uuid, + team_uuid, + action, + target_type, + target_uuid, + target_name, + Some(detail), + None, // changes + ip_address, + None, // user_agent + None, // request_id + ) + .await + { + tracing::error!("audit_log failed: {}", e); + } +} + +/// 记录审计日志(允许无用户,用于登录/注册等场景) +pub async fn log_audit_anonymous( + svc_ctx: &SvcCtx, + ctx: &RequestContext, + user_uuid: Uuid, + action: &str, + target_type: &str, + detail: &str, +) { + let ip_address = ctx.ip(); + + if let Err(e) = log_action_service( + svc_ctx, + user_uuid, + None, + action, + target_type, + Some(user_uuid), + None, + Some(detail), + None, + ip_address, + None, + None, + ) + .await + { + tracing::error!("audit_log_anonymous failed: {}", e); + } +} + +/// 记录审计日志 +pub async fn log_action_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + action: &str, + target_type: &str, + target_uuid: Option, + target_name: Option<&str>, + details: Option<&str>, + changes: Option<&serde_json::Value>, + ip_address: Option<&str>, + user_agent: Option<&str>, + request_id: Option<&str>, +) -> Result { + models::insert_audit_log( + &svc_ctx.db, + user_uuid, + team_uuid, + action, + target_type, + target_uuid, + target_name, + details, + changes, + ip_address, + user_agent, + request_id, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取审计日志列表 +pub async fn get_audit_logs_service( + svc_ctx: &SvcCtx, + current_user_uuid: Uuid, + team_uuid: Option, + payload: &ListAuditLogsRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let user_uuid_filter = payload.filters.as_ref().and_then(|f| f.user_uuid); + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let action = payload.filters.as_ref().and_then(|f| f.action.as_deref()); + let target_type = payload.filters.as_ref().and_then(|f| f.target_type.as_deref()); + + let logs = models::fetch_audit_logs( + &svc_ctx.db, + current_user_uuid, + team_uuid, + user_uuid_filter, + keyword, + action, + target_type, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_audit_logs_count( + &svc_ctx.db, + current_user_uuid, + team_uuid, + user_uuid_filter, + keyword, + action, + target_type, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((logs, total)) +} + +/// 获取审计日志详情 +pub async fn get_audit_log_service( + svc_ctx: &SvcCtx, + log_uuid: Uuid, +) -> Result { + models::fetch_audit_log_by_uuid(&svc_ctx.db, log_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "审计日志不存在".to_string()) +} + +/// 获取审计统计 +pub async fn get_audit_stats_service( + svc_ctx: &SvcCtx, + current_user_uuid: Uuid, + team_uuid: Option, +) -> Result { + // 总数 + let total_logs = models::fetch_audit_logs_count( + &svc_ctx.db, + current_user_uuid, + team_uuid, + None, + None, + None, + None, + ) + .await + .map_err(|e| e.to_string())?; + + // 今日数量 + let today = chrono::Utc::now().date_naive(); + let logs_today = models::fetch_audit_logs_count_by_date(&svc_ctx.db, team_uuid, today) + .await + .map_err(|e| e.to_string())?; + + // 本周数量 + let week_start = today - chrono::Duration::days(today.weekday().num_days_from_monday() as i64); + let logs_this_week = + models::fetch_audit_logs_count_since_date(&svc_ctx.db, team_uuid, week_start) + .await + .map_err(|e| e.to_string())?; + + // 本月数量 + let month_start = chrono::NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap(); + let logs_this_month = + models::fetch_audit_logs_count_since_date(&svc_ctx.db, team_uuid, month_start) + .await + .map_err(|e| e.to_string())?; + + // 热门操作(Top 5) + let top_actions_raw = models::fetch_top_actions(&svc_ctx.db, team_uuid, 5) + .await + .map_err(|e| e.to_string())?; + + // 热门目标类型(Top 5) + let top_target_types_raw = models::fetch_top_target_types(&svc_ctx.db, team_uuid, 5) + .await + .map_err(|e| e.to_string())?; + + Ok(AuditStatsResponse { + total_logs, + logs_today, + logs_this_week, + logs_this_month, + top_actions: top_actions_raw + .into_iter() + .map(|(action, count)| ActionCount { action, count }) + .collect(), + top_target_types: top_target_types_raw + .into_iter() + .map(|(target_type, count)| TargetTypeCount { target_type, count }) + .collect(), + }) +} + +/// 导出审计日志 +pub async fn export_audit_logs_service( + svc_ctx: &SvcCtx, + current_user_uuid: Uuid, + team_uuid: Option, + payload: &ExportAuditLogsRequest, +) -> Result<(String, String, String), String> { + // 构建查询请求 + let list_request = ListAuditLogsRequest { + pagination: crate::entitys::Pagination { + page: 1, + page_size: payload.max_records.unwrap_or(1000) as i64, + sort_by: None, + sort_order: None, + }, + filters: payload.filters.clone(), + }; + + let (logs, _total) = + get_audit_logs_service(svc_ctx, current_user_uuid, team_uuid, &list_request).await?; + + // 根据格式生成导出内容 + let content = match payload.format.as_str() { + "csv" => export_to_csv(&logs), + "json" => serde_json::to_string_pretty(&logs).unwrap_or_default(), + _ => return Err("不支持的导出格式".to_string()), + }; + + let filename = format!( + "audit_logs_{}.{}", + chrono::Utc::now().format("%Y%m%d%H%M%S"), + payload.format + ); + + let mime_type = match payload.format.as_str() { + "csv" => "text/csv".to_string(), + "json" => "application/json".to_string(), + _ => "text/plain".to_string(), + }; + + Ok((content, filename, mime_type)) +} + +fn export_to_csv(logs: &[AuditLogDto]) -> String { + let mut csv = String::from("时间,用户UUID,操作,目标类型,目标名称,详情,IP地址\n"); + for log in logs { + csv.push_str(&format!( + "{},{},{},{},{},{},{}\n", + log.created_at.format("%Y-%m-%d %H:%M:%S"), + log.user_uuid, + log.action, + log.target_type, + log.target_name.as_deref().unwrap_or(""), + log.details.as_deref().unwrap_or("").replace(',', ";"), + log.ip_address.as_deref().unwrap_or(""), + )); + } + csv +} diff --git a/src-tauri/crates/business/src/services/browser_kernels.rs b/src-tauri/crates/business/src/services/browser_kernels.rs new file mode 100644 index 00000000..57290a7e --- /dev/null +++ b/src-tauri/crates/business/src/services/browser_kernels.rs @@ -0,0 +1,705 @@ +use std::{collections::BTreeMap, path::Path, sync::OnceLock}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::FromRow; +use uuid::Uuid; + +use crate::database::DbPool; + +const DEFAULT_BROWSER_KERNELS_JSON: &str = + include_str!("../../resources/default-browser-kernels.json"); +const SUPPORTED_SCHEMA_VERSION: u32 = 1; +const KERNEL_TYPE_PREFIX: &str = "SIMPRINT_KERNEL_"; + +#[derive(Debug, Clone, Deserialize, Serialize, FromRow, PartialEq, Eq)] +pub struct BrowserKernelVersion { + pub kernel_id: String, + pub type_code: String, + pub resource_name: String, + pub install_dir_name: String, + pub version: String, + pub name: Option, + pub notes: Option, + pub platform: String, + pub url: Option, + pub hash: String, + pub signature: String, + pub compatible_signatures: sqlx::types::Json>, + pub file_size: Option, + pub is_latest: bool, + pub status: String, + pub arch: String, + pub package_format: String, + pub requires_extract: bool, + pub entrypoint_template: Option, + pub extract_root: Option, + pub installed: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct BrowserKernelRecord { + type_code: String, + resource_name: String, + #[serde(default)] + install_dir_name: Option, + version: String, + name: Option, + notes: Option, + platform: String, + url: String, + #[serde(default = "default_source_priority")] + priority: i32, + hash: String, + signature: String, + #[serde(default)] + compatible_signatures: Vec, + file_size: Option, + #[serde(default)] + is_latest: bool, + #[serde(default = "active_status")] + status: String, + arch: String, + package_format: String, + #[serde(default)] + requires_extract: bool, + entrypoint_template: Option, + extract_root: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct BrowserKernelCatalog { + schema_version: u32, + source_id: String, + kernels: Vec, +} + +#[derive(Serialize)] +struct BrowserKernelIdentity<'a> { + type_code: &'a str, + platform: &'a str, + arch: &'a str, + package_hash: &'a str, + executable_signature: &'a str, + package_format: &'a str, + requires_extract: bool, + entrypoint_template: Option<&'a str>, + extract_root: Option<&'a str>, +} + +static DEFAULT_CATALOG: OnceLock> = OnceLock::new(); + +fn active_status() -> String { + "active".to_string() +} + +fn default_source_priority() -> i32 { + 100 +} + +fn normalize_required(value: &str, field: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + Err(format!("Browser kernel field {field} cannot be empty")) + } else { + Ok(value.to_string()) + } +} + +fn kernel_id(record: &BrowserKernelRecord) -> Result { + let identity = BrowserKernelIdentity { + type_code: record.type_code.trim(), + platform: record.platform.trim(), + arch: record.arch.trim(), + package_hash: record.hash.trim(), + executable_signature: record.signature.trim(), + package_format: record.package_format.trim(), + requires_extract: record.requires_extract, + entrypoint_template: record.entrypoint_template.as_deref().map(str::trim), + extract_root: record.extract_root.as_deref().map(str::trim), + }; + let canonical = serde_json::to_vec(&identity) + .map_err(|error| format!("Failed to serialize browser kernel identity: {error}"))?; + Ok(hex::encode(Sha256::digest(canonical))) +} + +fn validate_catalog(catalog: BrowserKernelCatalog) -> Result { + if catalog.schema_version != SUPPORTED_SCHEMA_VERSION { + return Err(format!( + "Unsupported default browser kernel catalog schema version: {}", + catalog.schema_version + )); + } + normalize_required(&catalog.source_id, "source_id")?; + if catalog.kernels.is_empty() { + return Err("The default browser kernel catalog is empty".to_string()); + } + + for record in &catalog.kernels { + if !record.type_code.starts_with(KERNEL_TYPE_PREFIX) { + return Err(format!( + "Invalid browser kernel type code: {}", + record.type_code + )); + } + normalize_required(&record.resource_name, "resource_name")?; + if let Some(install_dir_name) = &record.install_dir_name { + normalize_required(install_dir_name, "install_dir_name")?; + } + normalize_required(&record.version, "version")?; + normalize_required(&record.platform, "platform")?; + normalize_required(&record.url, "url")?; + normalize_required(&record.hash, "hash")?; + normalize_required(&record.signature, "signature")?; + for signature in &record.compatible_signatures { + let signature = normalize_required(signature, "compatible_signatures")?; + if signature.len() != 64 || !signature.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(format!( + "Invalid compatible browser kernel signature: {signature}" + )); + } + } + normalize_required(&record.arch, "arch")?; + normalize_required(&record.package_format, "package_format")?; + kernel_id(record)?; + } + + Ok(catalog) +} + +fn parse_catalog(contents: &str, source_label: &str) -> Result { + let catalog: BrowserKernelCatalog = serde_json::from_str(contents).map_err(|error| { + format!("Failed to parse browser kernel catalog {source_label}: {error}") + })?; + validate_catalog(catalog) +} + +fn parse_default_catalog() -> Result { + parse_catalog(DEFAULT_BROWSER_KERNELS_JSON, "simprint-builtin") +} + +fn default_catalog() -> Result<&'static BrowserKernelCatalog, String> { + DEFAULT_CATALOG + .get_or_init(parse_default_catalog) + .as_ref() + .map_err(Clone::clone) +} + +async fn import_catalog(pool: &DbPool, catalog: &BrowserKernelCatalog) -> Result { + let mut tx = pool.begin().await.map_err(|error| error.to_string())?; + + for record in &catalog.kernels { + let kernel_id = kernel_id(record)?; + sqlx::query( + r#" + INSERT INTO browser_kernel_artifacts ( + kernel_id, type_code, resource_name, install_dir_name, version, name, notes, platform, + package_hash, executable_signature, compatible_executable_signatures, + file_size, is_latest, status, + arch, package_format, requires_extract, entrypoint_template, extract_root + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + ON CONFLICT (kernel_id) DO UPDATE SET + resource_name = excluded.resource_name, + version = excluded.version, + name = excluded.name, + notes = excluded.notes, + compatible_executable_signatures = excluded.compatible_executable_signatures, + file_size = excluded.file_size, + is_latest = excluded.is_latest, + status = excluded.status, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(&kernel_id) + .bind(record.type_code.trim()) + .bind(record.resource_name.trim()) + .bind( + record + .install_dir_name + .as_deref() + .map(str::trim) + .unwrap_or_else(|| record.resource_name.trim()), + ) + .bind(record.version.trim()) + .bind(record.name.as_deref().map(str::trim)) + .bind(record.notes.as_deref().map(str::trim)) + .bind(record.platform.trim()) + .bind(record.hash.trim()) + .bind(record.signature.trim()) + .bind(sqlx::types::Json(&record.compatible_signatures)) + .bind(record.file_size) + .bind(record.is_latest) + .bind(record.status.trim()) + .bind(record.arch.trim()) + .bind(record.package_format.trim()) + .bind(record.requires_extract) + .bind(record.entrypoint_template.as_deref().map(str::trim)) + .bind(record.extract_root.as_deref().map(str::trim)) + .execute(&mut *tx) + .await + .map_err(|error| error.to_string())?; + + sqlx::query( + "UPDATE browser_kernel_sources SET is_active = 0 \ + WHERE kernel_id = $1 AND source_id = $2", + ) + .bind(&kernel_id) + .bind(catalog.source_id.trim()) + .execute(&mut *tx) + .await + .map_err(|error| error.to_string())?; + + sqlx::query( + r#" + INSERT INTO browser_kernel_sources (kernel_id, source_id, url, priority) + VALUES ($1, $2, $3, $4) + ON CONFLICT (kernel_id, source_id, url) DO UPDATE SET + is_active = 1, + priority = excluded.priority, + last_seen_at = CURRENT_TIMESTAMP + "#, + ) + .bind(&kernel_id) + .bind(catalog.source_id.trim()) + .bind(record.url.trim()) + .bind(record.priority) + .execute(&mut *tx) + .await + .map_err(|error| error.to_string())?; + } + + tx.commit().await.map_err(|error| error.to_string())?; + Ok(catalog.kernels.len()) +} + +/// Import the bundled manifest into the runtime registry. Import is additive: +/// the content-derived kernel identity and existing environment bindings are +/// never replaced when a manifest record changes. +pub async fn import_default_catalog(pool: &DbPool) -> Result { + import_catalog(pool, default_catalog()?).await +} + +/// Import an optional user-maintained catalog with exactly the same rules as +/// the bundled catalog. Missing files are intentionally treated as no input. +pub async fn import_catalog_file(pool: &DbPool, path: &Path) -> Result { + if !path.exists() { + return Ok(0); + } + let contents = std::fs::read_to_string(path).map_err(|error| { + format!( + "Failed to read browser kernel catalog {}: {error}", + path.display() + ) + })?; + let catalog = parse_catalog(&contents, &path.display().to_string())?; + import_catalog(pool, &catalog).await +} + +const KERNEL_SELECT: &str = r#" + SELECT + a.kernel_id, + a.type_code, + a.resource_name, + COALESCE(a.install_dir_name, a.resource_name) AS install_dir_name, + a.version, + a.name, + a.notes, + a.platform, + ( + SELECT source.url + FROM browser_kernel_sources source + WHERE source.kernel_id = a.kernel_id AND source.is_active = 1 + ORDER BY source.priority ASC, source.id ASC + LIMIT 1 + ) AS url, + a.package_hash AS hash, + a.executable_signature AS signature, + a.compatible_executable_signatures AS compatible_signatures, + a.file_size, + a.is_latest, + a.status, + a.arch, + a.package_format, + a.requires_extract, + a.entrypoint_template, + a.extract_root, + EXISTS ( + SELECT 1 FROM browser_kernel_installations installation + WHERE installation.kernel_id = a.kernel_id AND installation.status = 'ready' + ) AS installed + FROM browser_kernel_artifacts a +"#; + +pub async fn list_browser_kernels( + pool: &DbPool, + platform: Option<&str>, + type_code: Option<&str>, +) -> Result>, String> { + let platform = platform.map(str::trim).filter(|value| !value.is_empty()); + let type_code = type_code.map(str::trim).filter(|value| !value.is_empty()); + let rows = sqlx::query_as::<_, BrowserKernelVersion>(&format!( + "{KERNEL_SELECT} WHERE a.status = 'active' ORDER BY a.is_latest DESC, a.version DESC" + )) + .fetch_all(pool) + .await + .map_err(|error| error.to_string())?; + let mut groups = BTreeMap::>::new(); + + for row in rows { + if platform.is_some_and(|value| !row.platform.eq_ignore_ascii_case(value)) { + continue; + } + if type_code.is_some_and(|value| row.type_code != value) { + continue; + } + if type_code.is_none() && !row.type_code.starts_with(KERNEL_TYPE_PREFIX) { + continue; + } + groups.entry(row.type_code.clone()).or_default().push(row); + } + + Ok(groups) +} + +pub async fn get_browser_kernel( + pool: &DbPool, + kernel_id: &str, +) -> Result, String> { + sqlx::query_as::<_, BrowserKernelVersion>(&format!( + "{KERNEL_SELECT} WHERE a.kernel_id = $1 LIMIT 1" + )) + .bind(kernel_id.trim()) + .fetch_optional(pool) + .await + .map_err(|error| error.to_string()) +} + +pub async fn find_browser_kernel_by_name( + pool: &DbPool, + resource_name: &str, +) -> Result, String> { + sqlx::query_as::<_, BrowserKernelVersion>(&format!( + "{KERNEL_SELECT} WHERE a.resource_name = $1 AND a.status = 'active' \ + ORDER BY a.is_latest DESC, a.version DESC LIMIT 1" + )) + .bind(resource_name.trim()) + .fetch_optional(pool) + .await + .map_err(|error| error.to_string()) +} + +pub async fn default_browser_kernel(pool: &DbPool) -> Result, String> { + sqlx::query_as::<_, BrowserKernelVersion>(&format!( + "{KERNEL_SELECT} WHERE a.type_code = 'SIMPRINT_KERNEL_CHROMIUM' \ + AND a.status = 'active' ORDER BY a.is_latest DESC, a.version DESC LIMIT 1" + )) + .fetch_optional(pool) + .await + .map_err(|error| error.to_string()) +} + +pub async fn bind_environment_kernel( + pool: &DbPool, + environment_uuid: Uuid, + kernel_id: &str, +) -> Result<(), String> { + if get_browser_kernel(pool, kernel_id).await?.is_none() { + return Err(format!( + "Browser kernel does not exist: {}", + kernel_id.trim() + )); + } + sqlx::query( + r#" + INSERT INTO environment_kernel_bindings (environment_uuid, kernel_id) + VALUES ($1, $2) + ON CONFLICT (environment_uuid) DO UPDATE SET + kernel_id = excluded.kernel_id, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(environment_uuid) + .bind(kernel_id.trim()) + .execute(pool) + .await + .map_err(|error| error.to_string())?; + Ok(()) +} + +pub async fn get_environment_kernel( + pool: &DbPool, + environment_uuid: Uuid, +) -> Result, String> { + sqlx::query_as::<_, BrowserKernelVersion>(&format!( + "{KERNEL_SELECT} INNER JOIN environment_kernel_bindings binding \ + ON binding.kernel_id = a.kernel_id WHERE binding.environment_uuid = $1 LIMIT 1" + )) + .bind(environment_uuid) + .fetch_optional(pool) + .await + .map_err(|error| error.to_string()) +} + +pub async fn resolve_requested_kernel( + pool: &DbPool, + window_info: &serde_json::Value, + allow_default: bool, +) -> Result { + if let Some(kernel_id) = window_info + .get("kernel_id") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return get_browser_kernel(pool, kernel_id) + .await? + .ok_or_else(|| format!("Browser kernel does not exist: {kernel_id}")); + } + if let Some(resource_name) = window_info + .get("kernel") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("chrome")) + { + if let Some(kernel) = find_browser_kernel_by_name(pool, resource_name).await? { + return Ok(kernel); + } + } + if allow_default { + if let Some(kernel) = default_browser_kernel(pool).await? { + return Ok(kernel); + } + } + Err("This environment has no valid browser kernel binding".to_string()) +} + +pub async fn migrate_legacy_environment_bindings(pool: &DbPool) -> Result { + let rows = sqlx::query_as::<_, (Uuid, String)>( + r#" + SELECT config.environment_uuid, config.window_info + FROM environment_configs config + LEFT JOIN environment_kernel_bindings binding + ON binding.environment_uuid = config.environment_uuid + WHERE binding.environment_uuid IS NULL + "#, + ) + .fetch_all(pool) + .await + .map_err(|error| error.to_string())?; + let mut migrated = 0; + + for (environment_uuid, raw_window_info) in rows { + let mut window_info: serde_json::Value = match serde_json::from_str(&raw_window_info) { + Ok(value) => value, + Err(_) => continue, + }; + let kernel = match resolve_requested_kernel(pool, &window_info, false).await { + Ok(kernel) => kernel, + Err(_) => continue, + }; + bind_environment_kernel(pool, environment_uuid, &kernel.kernel_id).await?; + if let Some(object) = window_info.as_object_mut() { + object.insert( + "kernel_id".to_string(), + serde_json::Value::String(kernel.kernel_id), + ); + sqlx::query( + "UPDATE environment_configs SET window_info = $1, updated_at = CURRENT_TIMESTAMP \ + WHERE environment_uuid = $2", + ) + .bind(window_info) + .bind(environment_uuid) + .execute(pool) + .await + .map_err(|error| error.to_string())?; + } + migrated += 1; + } + + Ok(migrated) +} + +pub async fn record_kernel_installation( + pool: &DbPool, + kernel_id: &str, + install_path: &str, + verified_signature: &str, +) -> Result<(), String> { + if get_browser_kernel(pool, kernel_id).await?.is_none() { + return Ok(()); + } + sqlx::query( + r#" + INSERT INTO browser_kernel_installations ( + kernel_id, install_path, verified_signature, status, verified_at + ) VALUES ($1, $2, $3, 'ready', CURRENT_TIMESTAMP) + ON CONFLICT (kernel_id) DO UPDATE SET + install_path = excluded.install_path, + verified_signature = excluded.verified_signature, + status = 'ready', + verified_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(kernel_id.trim()) + .bind(install_path) + .bind(verified_signature.trim()) + .execute(pool) + .await + .map_err(|error| error.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{database, utils::DatabaseConfig}; + + async fn test_pool() -> DbPool { + let mut config = DatabaseConfig::embedded("sqlite::memory:"); + config.max_connections = 1; + config.min_connections = 1; + let pool = database::connect(&config).await.unwrap(); + database::migrate(&pool).await.unwrap(); + pool + } + + #[test] + fn content_identity_ignores_display_and_download_location() { + let catalog = parse_default_catalog().unwrap(); + let mut changed = catalog.kernels[0].clone(); + let original_id = kernel_id(&changed).unwrap(); + changed.resource_name = "Renamed kernel".to_string(); + changed.url = "https://example.invalid/mirror.zip".to_string(); + assert_eq!(kernel_id(&changed).unwrap(), original_id); + + changed.hash = "different-package-hash".to_string(); + assert_ne!(kernel_id(&changed).unwrap(), original_id); + } + + #[tokio::test] + async fn imports_and_queries_the_bundled_catalog() { + let pool = test_pool().await; + import_default_catalog(&pool).await.unwrap(); + import_default_catalog(&pool).await.unwrap(); + + let groups = list_browser_kernels(&pool, Some("windows"), Some("SIMPRINT_KERNEL_CHROMIUM")) + .await + .unwrap(); + let kernels = &groups["SIMPRINT_KERNEL_CHROMIUM"]; + assert_eq!(kernels.len(), 1, "catalog import must be idempotent"); + assert_eq!(kernels[0].resource_name, "Chrome 144"); + assert_eq!(kernels[0].install_dir_name, "Chrome 144"); + assert_eq!(kernels[0].kernel_id.len(), 64); + assert_eq!(kernels[0].compatible_signatures.len(), 1); + assert_eq!( + kernels[0].compatible_signatures[0], + "26afa023c6637f045b6825a8e14b720a2305504b313f063bc8410bb0aef19bbf" + ); + assert!(kernels[0].url.as_deref().unwrap().starts_with("https://")); + } + + #[tokio::test] + async fn replaces_a_sources_url_without_replacing_the_artifact() { + let pool = test_pool().await; + let mut catalog = parse_default_catalog().unwrap(); + catalog.source_id = "test-user-source".to_string(); + catalog.kernels[0].priority = 10; + catalog.kernels[0].url = "https://example.invalid/first.zip".to_string(); + import_catalog(&pool, &catalog).await.unwrap(); + catalog.kernels[0].resource_name = "Renamed Chrome 144".to_string(); + catalog.kernels[0].install_dir_name = Some("Another directory".to_string()); + catalog.kernels[0].url = "https://example.invalid/second.zip".to_string(); + import_catalog(&pool, &catalog).await.unwrap(); + + let artifact_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM browser_kernel_artifacts") + .fetch_one(&pool) + .await + .unwrap(); + let active_source: String = sqlx::query_scalar( + "SELECT url FROM browser_kernel_sources \ + WHERE source_id = 'test-user-source' AND is_active = 1", + ) + .fetch_one(&pool) + .await + .unwrap(); + let active_source_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM browser_kernel_sources \ + WHERE source_id = 'test-user-source' AND is_active = 1", + ) + .fetch_one(&pool) + .await + .unwrap(); + let install_dir_name: String = + sqlx::query_scalar("SELECT install_dir_name FROM browser_kernel_artifacts") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(artifact_count, 1); + assert_eq!(active_source_count, 1); + assert_eq!(active_source, "https://example.invalid/second.zip"); + assert_eq!(install_dir_name, "Chrome 144"); + } + + #[tokio::test] + async fn migrates_legacy_environment_name_to_an_immutable_binding() { + let mut config = DatabaseConfig::embedded("sqlite::memory:"); + config.max_connections = 1; + config.min_connections = 1; + let context = crate::svc_ctx::SvcCtx::new(&config).await.unwrap(); + let (workspace_uuid, team_uuid): (Uuid, Uuid) = sqlx::query_as( + "SELECT current_workspace_uuid, current_team_uuid FROM user_infos \ + WHERE user_uuid = $1", + ) + .bind(context.local_user_uuid) + .fetch_one(&context.db) + .await + .unwrap(); + let environment_uuid = crate::models::environments::insert_environment( + &context.db, + workspace_uuid, + context.local_user_uuid, + team_uuid, + "Legacy browser", + None, + None, + None, + Some("Windows"), + Some("Chrome 144"), + ) + .await + .unwrap(); + crate::models::environments::upsert_environment_config( + &context.db, + environment_uuid, + &serde_json::json!({ "kernel": "Chrome 144" }), + &serde_json::json!({}), + &serde_json::json!({}), + &serde_json::json!({}), + &serde_json::json!({}), + &serde_json::json!({}), + ) + .await + .unwrap(); + + assert_eq!( + migrate_legacy_environment_bindings(&context.db).await.unwrap(), + 1 + ); + let bound = get_environment_kernel(&context.db, environment_uuid).await.unwrap().unwrap(); + assert_eq!(bound.resource_name, "Chrome 144"); + assert_eq!(bound.kernel_id.len(), 64); + + let stored = + crate::models::environments::fetch_environment_config(&context.db, environment_uuid) + .await + .unwrap() + .unwrap(); + assert_eq!( + stored.window_info["kernel_id"].as_str(), + Some(bound.kernel_id.as_str()) + ); + } +} diff --git a/src-tauri/crates/business/src/services/environments.rs b/src-tauri/crates/business/src/services/environments.rs new file mode 100644 index 00000000..bb9a76dc --- /dev/null +++ b/src-tauri/crates/business/src/services/environments.rs @@ -0,0 +1,1644 @@ +use std::collections::HashMap; +use url::Url; +use uuid::Uuid; + +use crate::dto::{ + EnvironmentConfigDto, EnvironmentCookieDto, EnvironmentCookieGroupDto, EnvironmentDto, + EnvironmentUrlDto, GroupSummaryDto, PlatformAccountDto, ProxySummaryDto, TagDto, +}; +use crate::entitys::{ + AddEnvironmentCookieRequest, AddEnvironmentUrlRequest, AssignTagsRequest, + BatchAssignTagRequest, BatchCreateEnvironmentRequest, BatchMoveToGroupRequest, + BatchRemoveTagsRequest, CookieGroupInput, CookieInput, CreateEnvironmentRequest, + ListEnvironmentsRequest, MoveToGroupRequest, SetEnvironmentProxyRequest, + UpdateEnvironmentRequest, UrlInput, +}; +use crate::models; +use crate::services::accounts; +use crate::svc_ctx::SvcCtx; + +// ============ Environments ============ + +fn window_info_with_kernel_id( + window_info: &serde_json::Value, + kernel: &crate::services::browser_kernels::BrowserKernelVersion, +) -> serde_json::Value { + let mut window_info = window_info.clone(); + if let Some(object) = window_info.as_object_mut() { + object.insert( + "kernel_id".to_string(), + serde_json::Value::String(kernel.kernel_id.clone()), + ); + object.insert( + "kernel".to_string(), + serde_json::Value::String(kernel.resource_name.clone()), + ); + } + window_info +} + +/// 创建环境 +pub async fn create_environment_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &CreateEnvironmentRequest, +) -> Result { + let selected_kernel = crate::services::browser_kernels::resolve_requested_kernel( + &svc_ctx.db, + &payload.config.window_info, + true, + ) + .await?; + let window_info = window_info_with_kernel_id(&payload.config.window_info, &selected_kernel); + + // 1. 检查用户是否在当前工作空间的团队中(工作空间级别) + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 权限检查 + if let Some(group_uuid) = payload.group_uuid { + // 如果指定了分组,检查用户是否有目标分组的 write 或 manage 权限 + let has_write = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "write", + ) + .await + .map_err(|e| e.to_string())?; + + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_write && !has_manage { + return Err("您没有在该分组中创建环境的权限".to_string()); + } + } else { + // 如果未指定分组,检查用户是否有团队级别的环境创建权限(Editor/Admin/Owner) + let can_create = matches!(team_member.role.as_str(), "owner" | "admin" | "editor"); + if !can_create { + return Err("您没有创建环境的权限,需要 Editor 及以上角色".to_string()); + } + } + + // 3. 检查工作空间配额是否充足 + let quota_available = models::check_quota(&svc_ctx.db, workspace_uuid, "environments") + .await + .map_err(|e| e.to_string())?; + if !quota_available { + return Err("工作空间环境配额不足,无法创建新环境".to_string()); + } + + // 提取系统信息 + let system_info = payload + .config + .window_info + .get("system") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let kernel_info = Some(selected_kernel.resource_name.clone()); + + // 创建环境 + let env_uuid = models::insert_environment( + &svc_ctx.db, + workspace_uuid, + user_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + payload.group_uuid, + payload.proxy_uuid, + system_info.as_deref(), + kernel_info.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + // 创建环境配置 + models::upsert_environment_config( + &svc_ctx.db, + env_uuid, + &window_info, + &payload.config.basic_settings, + &payload.config.fingerprint_settings, + &payload.config.device_settings, + &payload.config.preference_settings, + &payload.config.project_metadata, + ) + .await + .map_err(|e| e.to_string())?; + + crate::services::browser_kernels::bind_environment_kernel( + &svc_ctx.db, + env_uuid, + &selected_kernel.kernel_id, + ) + .await?; + + replace_environment_urls(svc_ctx, env_uuid, payload.urls.as_deref()).await?; + replace_environment_cookies(svc_ctx, env_uuid, payload.cookies.as_deref()).await?; + + // 分配标签 + if let Some(tag_uuids) = &payload.tag_uuids { + for tag_uuid in tag_uuids { + let _ = models::insert_environment_tag(&svc_ctx.db, env_uuid, *tag_uuid).await; + } + } + + // 关联账号 + if let Some(account_uuids) = &payload.account_uuids { + for (idx, account_uuid) in account_uuids.iter().enumerate() { + let _ = models::accounts::insert_environment_account( + &svc_ctx.db, + env_uuid, + *account_uuid, + idx as i32, + ) + .await; + } + } + + // 4. 更新工作空间配额(创建后增加使用数) + models::increment_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(env_uuid) +} + +#[derive(Debug, Clone)] +struct CookieSiteTarget { + site_input: String, + domain: String, + path: String, + secure: bool, +} + +fn normalize_cookie_path(path: &str) -> String { + if path.trim().is_empty() || path == "/" { + "/".to_string() + } else if path.starts_with('/') { + path.to_string() + } else { + format!("/{}", path) + } +} + +fn parse_cookie_site(site: &str) -> Result { + let site = site.trim(); + if site.is_empty() { + return Err("Cookie 目标网页/域名不能为空".to_string()); + } + + if let Ok(parsed) = Url::parse(site) { + let host = parsed.host_str().ok_or_else(|| format!("无效的 Cookie 目标网页: {}", site))?; + + return Ok(CookieSiteTarget { + site_input: site.to_string(), + domain: host.to_string(), + path: normalize_cookie_path(parsed.path()), + secure: parsed.scheme().eq_ignore_ascii_case("https"), + }); + } + + if !site.contains("://") { + if site.starts_with('.') + && !site.contains('/') + && !site.chars().any(|ch| ch.is_whitespace()) + { + return Ok(CookieSiteTarget { + site_input: site.to_string(), + domain: site.to_string(), + path: "/".to_string(), + secure: false, + }); + } + + let candidate = format!("https://{}", site); + if let Ok(parsed) = Url::parse(&candidate) { + if let Some(host) = parsed.host_str() { + return Ok(CookieSiteTarget { + site_input: site.to_string(), + domain: host.to_string(), + path: normalize_cookie_path(parsed.path()), + secure: false, + }); + } + } + } + + Err(format!("无效的 Cookie 目标网页/域名: {}", site)) +} + +fn is_cookie_attribute(part: &str) -> bool { + let lower = part.trim().to_lowercase(); + matches!( + lower.as_str(), + "secure" | "httponly" | "http-only" | "partitioned" + ) || lower.starts_with("domain=") + || lower.starts_with("path=") + || lower.starts_with("expires=") + || lower.starts_with("max-age=") + || lower.starts_with("samesite=") +} + +fn parse_cookie_name_value(part: &str) -> Result<(String, String), String> { + let Some(eq_pos) = part.find('=') else { + return Err(format!("无效的 Cookie 格式: {}", part)); + }; + + let name = part[..eq_pos].trim(); + let value = part[eq_pos + 1..].trim(); + if name.is_empty() { + return Err(format!("Cookie 名称不能为空: {}", part)); + } + + Ok((name.to_string(), value.to_string())) +} + +fn parse_cookie_line_with_attrs( + line: &str, + site_target: &CookieSiteTarget, +) -> Result { + let parts: Vec<&str> = line + .split(';') + .map(|part| part.trim()) + .filter(|part| !part.is_empty()) + .collect(); + let (name, value) = parse_cookie_name_value( + parts.first().copied().ok_or_else(|| "Cookie 内容不能为空".to_string())?, + )?; + + let mut domain = site_target.domain.clone(); + let mut path = site_target.path.clone(); + let mut http_only = false; + let mut secure = site_target.secure; + let mut same_site = Some("Lax".to_string()); + + for part in parts.iter().skip(1) { + let lower = part.to_lowercase(); + if lower.starts_with("domain=") { + domain = part[7..].trim().to_string(); + } else if lower.starts_with("path=") { + path = normalize_cookie_path(part[5..].trim()); + } else if lower == "secure" { + secure = true; + } else if lower == "httponly" || lower == "http-only" { + http_only = true; + } else if lower.starts_with("samesite=") { + same_site = Some(part[9..].trim().to_string()); + } + } + + Ok(CookieInput { + site_input: site_target.site_input.clone(), + domain, + name, + value, + path: Some(path), + http_only: Some(http_only), + secure: Some(secure), + same_site, + }) +} + +fn parse_cookie_group(group: &CookieGroupInput) -> Result, String> { + let site_target = parse_cookie_site(&group.site)?; + let cookie_text = group.cookie_text.trim(); + if cookie_text.is_empty() { + return Err("Cookie 内容不能为空".to_string()); + } + + let mut cookies = Vec::new(); + + for line in cookie_text.lines().map(|line| line.trim()).filter(|line| !line.is_empty()) { + let parts: Vec<&str> = line + .split(';') + .map(|part| part.trim()) + .filter(|part| !part.is_empty()) + .collect(); + + if parts.is_empty() { + continue; + } + + let has_attr_style = + parts.len() > 1 && parts.iter().skip(1).all(|part| is_cookie_attribute(part)); + if has_attr_style { + cookies.push(parse_cookie_line_with_attrs(line, &site_target)?); + continue; + } + + for part in parts { + let (name, value) = parse_cookie_name_value(part)?; + cookies.push(CookieInput { + site_input: site_target.site_input.clone(), + domain: site_target.domain.clone(), + name, + value, + path: Some(site_target.path.clone()), + http_only: Some(false), + secure: Some(site_target.secure), + same_site: Some("Lax".to_string()), + }); + } + } + + if cookies.is_empty() { + return Err("Cookie 内容不能为空".to_string()); + } + + Ok(cookies) +} + +fn format_cookie_row( + cookie: &EnvironmentCookieDto, + site_target: Option<&CookieSiteTarget>, +) -> String { + let mut parts = vec![format!("{}={}", cookie.name, cookie.value)]; + + let default_domain = site_target.map(|target| target.domain.as_str()).unwrap_or(""); + let default_path = site_target.map(|target| target.path.as_str()).unwrap_or("/"); + let default_secure = site_target.map(|target| target.secure).unwrap_or(false); + + if !cookie.domain.trim().is_empty() && cookie.domain != default_domain { + parts.push(format!("domain={}", cookie.domain)); + } + + let cookie_path = cookie.path.as_deref().unwrap_or("/"); + if cookie_path != default_path { + parts.push(format!("path={}", cookie_path)); + } + + if cookie.secure.unwrap_or(false) != default_secure && cookie.secure.unwrap_or(false) { + parts.push("secure".to_string()); + } + + if cookie.http_only.unwrap_or(false) { + parts.push("httpOnly".to_string()); + } + + if let Some(same_site) = cookie + .same_site + .as_deref() + .filter(|same_site| !same_site.trim().is_empty() && *same_site != "Lax") + { + parts.push(format!("sameSite={}", same_site)); + } + + parts.join("; ") +} + +fn group_cookie_rows(cookie_rows: Vec) -> Vec { + let mut grouped: HashMap> = HashMap::new(); + + for cookie in cookie_rows { + grouped.entry(cookie.site_input.clone()).or_default().push(cookie); + } + + let mut items: Vec = grouped + .into_iter() + .map(|(site, cookies)| { + let site_target = parse_cookie_site(&site).ok(); + let simple_only = cookies.iter().all(|cookie| { + let default_domain = + site_target.as_ref().map(|target| target.domain.as_str()).unwrap_or(""); + let default_path = + site_target.as_ref().map(|target| target.path.as_str()).unwrap_or("/"); + let default_secure = + site_target.as_ref().map(|target| target.secure).unwrap_or(false); + + cookie.domain == default_domain + && cookie.path.as_deref().unwrap_or("/") == default_path + && cookie.secure.unwrap_or(false) == default_secure + && !cookie.http_only.unwrap_or(false) + && cookie + .same_site + .as_deref() + .map(|same_site| same_site.eq_ignore_ascii_case("lax")) + .unwrap_or(true) + && cookie.expires_at.is_none() + }); + + let parts: Vec = cookies + .iter() + .map(|cookie| format_cookie_row(cookie, site_target.as_ref())) + .collect(); + + EnvironmentCookieGroupDto { + site, + cookie_text: if simple_only { + parts.join("; ") + } else { + parts.join("\n") + }, + } + }) + .collect(); + + items.sort_by(|left, right| left.site.cmp(&right.site)); + items +} + +async fn replace_environment_urls( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + urls: Option<&[UrlInput]>, +) -> Result<(), String> { + let Some(urls) = urls else { + return Ok(()); + }; + + models::clear_environment_urls(&svc_ctx.db, env_uuid) + .await + .map_err(|e| format!("清空 URLs 失败: {}", e))?; + + for (idx, item) in urls.iter().enumerate() { + let url = item.url.trim(); + if url.is_empty() { + continue; + } + + models::insert_environment_url( + &svc_ctx.db, + env_uuid, + url, + item.title.as_deref(), + item.sort_order.or(Some(idx as i32)), + ) + .await + .map_err(|e| format!("保存 URL 失败: {}", e))?; + } + + Ok(()) +} + +async fn replace_environment_cookies( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + cookie_groups: Option<&[CookieGroupInput]>, +) -> Result<(), String> { + let Some(cookie_groups) = cookie_groups else { + return Ok(()); + }; + + models::clear_environment_cookies(&svc_ctx.db, env_uuid) + .await + .map_err(|e| format!("清空 Cookies 失败: {}", e))?; + + let mut parsed_cookies = Vec::new(); + for group in cookie_groups { + let mut items = parse_cookie_group(group)?; + parsed_cookies.append(&mut items); + } + + if parsed_cookies.is_empty() { + return Ok(()); + } + + models::batch_insert_environment_cookies(&svc_ctx.db, env_uuid, &parsed_cookies) + .await + .map_err(|e| format!("保存 Cookies 失败: {}", e))?; + + Ok(()) +} + +/// 获取环境列表(包含完整关联数据) +pub async fn get_environments_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &ListEnvironmentsRequest, +) -> Result<(Vec, i64), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let group_uuid = payload.filters.as_ref().and_then(|f| f.group_uuid); + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let tag_uuids = payload.filters.as_ref().and_then(|f| f.tag_uuids.as_deref()); + + // 2. 查询环境总数(用于分页) + let total_count = models::fetch_environments_count( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + status, + keyword, + tag_uuids, + ) + .await + .map_err(|e| e.to_string())?; + + // 3. 查询环境基础列表 + let env_rows = models::fetch_environments_base( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + status, + keyword, + tag_uuids, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + // 2. 权限过滤:根据分组权限过滤环境 + let is_owner_or_admin = matches!(team_member.role.as_str(), "owner" | "admin"); + + // 收集所有需要检查权限的分组 UUID(去重) + let unique_group_uuids: Vec = env_rows + .iter() + .filter_map(|row| row.group_uuid) + .collect::>() + .into_iter() + .collect(); + + // 批量检查分组权限(如果不是 Owner/Admin) + let mut group_permissions_cache: std::collections::HashMap = + std::collections::HashMap::new(); + if !is_owner_or_admin && !unique_group_uuids.is_empty() { + for group_uuid in unique_group_uuids { + let has_permission = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "read", + ) + .await + .map_err(|e| e.to_string())?; + group_permissions_cache.insert(group_uuid, has_permission); + } + } + + // 8. 过滤无权限的环境 + let filtered_env_rows: Vec<_> = env_rows + .into_iter() + .filter(|row| { + if let Some(group_uuid) = row.group_uuid { + // 如果环境有分组,检查用户是否有分组的 read 权限 + if is_owner_or_admin { + true // Owner/Admin 自动拥有所有分组权限 + } else { + *group_permissions_cache.get(&group_uuid).unwrap_or(&false) + } + } else { + // 如果环境无分组,所有团队成员都可以查看(已在团队成员检查中验证) + true + } + }) + .collect(); + + // 9. 重新收集关联 UUID(基于过滤后的环境) + let env_uuids: Vec = filtered_env_rows.iter().map(|e| e.uuid).collect(); + let group_uuids: Vec = filtered_env_rows.iter().filter_map(|e| e.group_uuid).collect(); + let proxy_uuids: Vec = filtered_env_rows.iter().filter_map(|e| e.proxy_uuid).collect(); + + // 10. 重新查询关联数据(基于过滤后的环境) + // 批量查询环境配置 + let mut configs_map: HashMap = HashMap::new(); + if !env_uuids.is_empty() { + let config_rows = models::fetch_environment_configs_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + for config in config_rows { + configs_map.insert(config.environment_uuid, config); + } + } + + // 批量查询分组 + let group_rows = if !group_uuids.is_empty() { + models::fetch_groups_by_uuids(&svc_ctx.db, &group_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let groups_map: HashMap = group_rows.into_iter().map(|g| (g.uuid, g)).collect(); + + // 批量查询代理 + let proxy_rows = if !proxy_uuids.is_empty() { + models::fetch_proxies_by_uuids(&svc_ctx.db, &proxy_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let proxies_map: HashMap = proxy_rows.into_iter().map(|p| (p.uuid, p)).collect(); + + // 批量查询标签 + let tag_rows = if !env_uuids.is_empty() { + models::fetch_tags_for_environments(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + + // 按环境分组标签 + let mut tags_map: HashMap> = HashMap::new(); + for tag_row in tag_rows { + tags_map.entry(tag_row.environment_uuid).or_default().push(TagDto { + id: tag_row.tag_id, + uuid: tag_row.tag_uuid, + user_uuid: tag_row.tag_user_uuid, + team_uuid: tag_row.tag_team_uuid, + name: tag_row.tag_name, + color: tag_row.tag_color, + sort_order: tag_row.tag_sort_order, + environments_count: tag_row.tag_environments_count, + created_at: tag_row.tag_created_at, + updated_at: tag_row.tag_updated_at, + deleted_at: tag_row.tag_deleted_at, + }); + } + + let mut urls_map: HashMap> = HashMap::new(); + if !env_uuids.is_empty() { + let url_rows = models::fetch_environment_urls_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + for url in url_rows { + urls_map.entry(url.environment_uuid).or_default().push(url); + } + } + + let mut cookies_map: HashMap> = HashMap::new(); + if !env_uuids.is_empty() { + let cookie_rows = models::fetch_environment_cookies_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + let mut grouped_rows: HashMap> = HashMap::new(); + for cookie in cookie_rows { + grouped_rows.entry(cookie.environment_uuid).or_default().push(cookie); + } + for (environment_uuid, rows) in grouped_rows { + cookies_map.insert(environment_uuid, group_cookie_rows(rows)); + } + } + + // 批量查询账号 + let mut accounts_map: HashMap> = HashMap::new(); + for env_uuid in &env_uuids { + let accounts = accounts::get_environment_accounts_service(&svc_ctx, *env_uuid) + .await + .unwrap_or_default(); + accounts_map.insert(*env_uuid, accounts); + } + + // 11. 组装完整数据(使用与环境详情一致的数据结构) + let environments: Vec = filtered_env_rows + .into_iter() + .map(|row| { + // 构建 EnvironmentDto + let environment = EnvironmentDto { + id: row.id, + uuid: row.uuid, + workspace_uuid: row.workspace_uuid, + user_uuid: row.user_uuid, + team_uuid: row.team_uuid, + name: row.name, + description: row.description, + status: row.status, + group_uuid: row.group_uuid, + proxy_uuid: row.proxy_uuid, + system_info: row.system_info, + kernel_info: row.kernel_info, + fingerprint_summary: row.fingerprint_summary, + last_opened_at: row.last_opened_at, + created_at: row.created_at, + updated_at: row.updated_at, + deleted_at: None, + }; + + // 分组详情 + let group = row.group_uuid.and_then(|uuid| { + groups_map.get(&uuid).map(|g| GroupSummaryDto { + id: g.id, + uuid: g.uuid, + name: g.name.clone(), + description: g.description.clone(), + sort_order: g.sort_order, + }) + }); + + // 代理详情 + let proxy = row.proxy_uuid.and_then(|uuid| { + proxies_map.get(&uuid).map(|p| ProxySummaryDto { + id: p.id, + uuid: p.uuid, + name: p.name.clone(), + host: p.host.clone(), + port: p.port, + proxy_type: p.proxy_type.clone(), + username: p.username.clone(), + password: p.password.clone(), + country: p.country.clone(), + city: p.city.clone(), + status: p.status.clone(), + latency: p.latency, + last_check_ip: p.last_check_ip.clone(), + }) + }); + + crate::entitys::EnvironmentDetailResponse { + environment, + config: configs_map.remove(&row.uuid), // 返回配置信息,用于传递给指纹浏览器内核 + cookies: cookies_map.remove(&row.uuid).unwrap_or_default(), + urls: urls_map.remove(&row.uuid).unwrap_or_default(), + tags: tags_map.remove(&row.uuid).unwrap_or_default(), + accounts: accounts_map.remove(&row.uuid).unwrap_or_default(), + group, + proxy, + extensions: Vec::new(), + } + }) + .collect(); + + // 返回过滤后的环境列表和数据库总数(用于分页显示) + // 注意:total 是数据库中符合条件的总数,不考虑权限过滤 + // 权限过滤只影响当前页返回的数据,不影响总数统计 + Ok((environments, total_count)) +} + +/// 获取环境详情 +pub async fn get_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result { + // 1. 检查用户是否在当前工作空间的团队中 + let _team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 read/write/manage 权限 + // Owner/Admin 自动拥有所有分组权限(已在 check_group_permission 中处理) + let has_permission = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "read", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_permission { + return Err("您没有查看该环境的权限".to_string()); + } + } + // 如果环境无分组,所有团队成员都可以查看(已在团队成员检查中验证) + + Ok(environment) +} + +/// 获取环境配置 +pub async fn get_environment_config_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::fetch_environment_config(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境配置不存在".to_string()) +} + +/// 获取环境的标签 +pub async fn get_environment_tags_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_tags(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境详情(包含完整关联数据) +pub async fn get_environment_detail_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result { + let environment = + get_environment_service(svc_ctx, workspace_uuid, team_uuid, user_uuid, env_uuid).await?; + + let config = get_environment_config_service(svc_ctx, env_uuid).await.ok(); + let cookies = get_environment_cookies_service(svc_ctx, env_uuid).await.unwrap_or_default(); + let urls = get_environment_urls_service(svc_ctx, env_uuid).await.unwrap_or_default(); + + let tags = get_environment_tags_service(svc_ctx, env_uuid).await?; + + let accounts = accounts::get_environment_accounts_service(svc_ctx, env_uuid) + .await + .unwrap_or_default(); + + // 获取分组信息 + let group = if let Some(group_uuid) = environment.group_uuid { + get_group_summary_service(svc_ctx, group_uuid).await.ok() + } else { + None + }; + + // 获取代理信息 + let proxy = if let Some(proxy_uuid) = environment.proxy_uuid { + get_proxy_summary_service(svc_ctx, proxy_uuid).await.ok() + } else { + None + }; + + Ok(crate::entitys::EnvironmentDetailResponse { + environment, + config, + cookies, + urls, + tags, + accounts, + group, + proxy, + extensions: Vec::new(), + }) +} + +/// 获取分组摘要信息 +pub async fn get_group_summary_service( + svc_ctx: &SvcCtx, + group_uuid: Uuid, +) -> Result { + let group = models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + Ok(crate::dto::GroupSummaryDto { + id: group.id, + uuid: group.uuid, + name: group.name, + description: group.description, + sort_order: group.sort_order, + }) +} + +/// 获取代理摘要信息 +pub async fn get_proxy_summary_service( + svc_ctx: &SvcCtx, + proxy_uuid: Uuid, +) -> Result { + let proxy = crate::models::proxies::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + Ok(crate::dto::ProxySummaryDto { + id: proxy.id, + uuid: proxy.uuid, + name: proxy.name, + host: proxy.host, + port: proxy.port, + proxy_type: proxy.proxy_type, + username: proxy.username, + password: proxy.password, + country: proxy.country, + city: proxy.city, + status: proxy.status, + latency: proxy.latency, + last_check_ip: proxy.last_check_ip, + }) +} + +/// 更新环境 +pub async fn update_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &UpdateEnvironmentRequest, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, payload.uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 write 或 manage 权限 + let has_write = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "write", + ) + .await + .map_err(|e| e.to_string())?; + + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_write && !has_manage { + return Err("您没有编辑该环境的权限".to_string()); + } + } else { + // 如果环境无分组,检查用户是否有团队级别的编辑权限(Editor/Admin/Owner) + let can_edit = matches!(team_member.role.as_str(), "owner" | "admin" | "editor"); + if !can_edit { + return Err("您没有编辑环境的权限,需要 Editor 及以上角色".to_string()); + } + } + + models::update_environment( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.group_uuid, + ) + .await + .map_err(|e| e.to_string())?; + + // 更新配置 + if let Some(config) = &payload.config { + let selected_kernel = if config + .window_info + .get("kernel_id") + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.trim().is_empty()) + { + crate::services::browser_kernels::resolve_requested_kernel( + &svc_ctx.db, + &config.window_info, + false, + ) + .await? + } else if let Some(bound) = + crate::services::browser_kernels::get_environment_kernel(&svc_ctx.db, payload.uuid) + .await? + { + bound + } else { + crate::services::browser_kernels::resolve_requested_kernel( + &svc_ctx.db, + &config.window_info, + false, + ) + .await? + }; + let window_info = window_info_with_kernel_id(&config.window_info, &selected_kernel); + models::upsert_environment_config( + &svc_ctx.db, + payload.uuid, + &window_info, + &config.basic_settings, + &config.fingerprint_settings, + &config.device_settings, + &config.preference_settings, + &config.project_metadata, + ) + .await + .map_err(|e| e.to_string())?; + crate::services::browser_kernels::bind_environment_kernel( + &svc_ctx.db, + payload.uuid, + &selected_kernel.kernel_id, + ) + .await?; + models::update_environment_kernel_info( + &svc_ctx.db, + payload.uuid, + &selected_kernel.resource_name, + ) + .await + .map_err(|error| error.to_string())?; + } + + replace_environment_urls(svc_ctx, payload.uuid, payload.urls.as_deref()).await?; + replace_environment_cookies(svc_ctx, payload.uuid, payload.cookies.as_deref()).await?; + + Ok(()) +} + +/// 设置环境代理 +pub async fn set_environment_proxy_service( + svc_ctx: &SvcCtx, + payload: &SetEnvironmentProxyRequest, +) -> Result<(), String> { + models::update_environment_proxy(&svc_ctx.db, payload.uuid, payload.proxy_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 分配标签 +pub async fn assign_tags_service( + svc_ctx: &SvcCtx, + payload: &AssignTagsRequest, +) -> Result<(), String> { + for tag_uuid in &payload.tag_uuids { + models::insert_environment_tag(&svc_ctx.db, payload.uuid, *tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 移除标签 +pub async fn remove_tag_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + tag_uuid: Uuid, +) -> Result<(), String> { + models::remove_environment_tag(&svc_ctx.db, env_uuid, tag_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 移动到分组 +pub async fn move_to_group_service( + svc_ctx: &SvcCtx, + payload: &MoveToGroupRequest, +) -> Result<(), String> { + models::update_environment(&svc_ctx.db, payload.uuid, None, None, payload.group_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量移动到分组 +pub async fn batch_move_to_group_service( + svc_ctx: &SvcCtx, + payload: &BatchMoveToGroupRequest, +) -> Result<(), String> { + for env_uuid in &payload.env_uuids { + models::update_environment(&svc_ctx.db, *env_uuid, None, None, Some(payload.group_uuid)) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 批量分配标签 +pub async fn batch_assign_tags_service( + svc_ctx: &SvcCtx, + payload: &BatchAssignTagRequest, +) -> Result<(), String> { + for env_uuid in &payload.env_uuids { + models::insert_environment_tag(&svc_ctx.db, *env_uuid, payload.tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 批量移除标签 +pub async fn batch_remove_tags_service( + svc_ctx: &SvcCtx, + payload: &BatchRemoveTagsRequest, +) -> Result<(), String> { + if let Some(tag_uuid) = payload.tag_uuid { + // 移除指定的标签 + for env_uuid in &payload.env_uuids { + models::remove_environment_tag(&svc_ctx.db, *env_uuid, tag_uuid) + .await + .map_err(|e| e.to_string())?; + } + } else { + // 移除所有标签 + for env_uuid in &payload.env_uuids { + models::clear_environment_tags(&svc_ctx.db, *env_uuid) + .await + .map_err(|e| e.to_string())?; + } + } + Ok(()) +} + +/// 删除环境 +pub async fn delete_environment_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + env_uuid: Uuid, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = + models::teams::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询环境(带工作空间过滤) + let environment = models::fetch_environment_by_uuid(&svc_ctx.db, workspace_uuid, env_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "环境不存在或不属于当前工作空间".to_string())?; + + // 3. 验证环境属于指定团队 + if environment.team_uuid != team_uuid { + return Err("环境不属于指定团队".to_string()); + } + + // 4. 权限检查 + if let Some(group_uuid) = environment.group_uuid { + // 如果环境有分组,检查用户是否有分组的 manage 权限 + let has_manage = models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !has_manage { + return Err("您没有删除该环境的权限".to_string()); + } + } else { + // 如果环境无分组,检查用户是否有团队级别的删除权限(Owner/Admin) + let can_delete = matches!(team_member.role.as_str(), "owner" | "admin"); + if !can_delete { + return Err("您没有删除环境的权限,需要 Owner 或 Admin 角色".to_string()); + } + } + + // 5. 删除环境 + models::delete_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + // 6. 更新工作空间配额(删除后减少使用数) + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 批量删除环境 +pub async fn batch_delete_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], +) -> Result { + models::batch_delete_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string()) +} + +// ============ Recycle Bin ============ + +/// 查询回收站环境列表 +pub async fn get_recycle_bin_environments_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + _user_uuid: Uuid, + payload: &ListEnvironmentsRequest, +) -> Result<(Vec, i64), String> { + // 1. 提取过滤参数 + let group_uuid = payload.filters.as_ref().and_then(|f| f.group_uuid); + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + + // 2. 计算分页参数 + let page = payload.pagination.page; + let page_size = payload.pagination.page_size; + let offset = (page - 1) * page_size; + let limit = page_size; + + // 3. 查询回收站环境总数 + let total_count = models::fetch_deleted_environments_count( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + keyword, + ) + .await + .map_err(|e| e.to_string())?; + + // 4. 查询回收站环境基础列表 + let env_rows = models::fetch_deleted_environments_base( + &svc_ctx.db, + workspace_uuid, + team_uuid, + group_uuid, + keyword, + offset, + limit, + ) + .await + .map_err(|e| e.to_string())?; + + if env_rows.is_empty() { + return Ok((vec![], total_count)); + } + + // 5. 获取所有环境的 UUID + let env_uuids: Vec = env_rows.iter().map(|e| e.uuid).collect(); + let group_uuids: Vec = env_rows.iter().filter_map(|e| e.group_uuid).collect(); + let proxy_uuids: Vec = env_rows.iter().filter_map(|e| e.proxy_uuid).collect(); + + // 6. 批量查询分组信息 + let group_rows = if !group_uuids.is_empty() { + models::fetch_groups_by_uuids(&svc_ctx.db, &group_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let group_map: HashMap = group_rows + .into_iter() + .map(|g| { + ( + g.uuid, + GroupSummaryDto { + id: g.id, + uuid: g.uuid, + name: g.name, + description: g.description, + sort_order: g.sort_order, + }, + ) + }) + .collect(); + + // 7. 批量查询代理信息 + let proxy_rows = if !proxy_uuids.is_empty() { + models::fetch_proxies_by_uuids(&svc_ctx.db, &proxy_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let proxy_map: HashMap = proxy_rows + .into_iter() + .map(|p| { + ( + p.uuid, + ProxySummaryDto { + id: p.id, + uuid: p.uuid, + name: p.name, + host: p.host, + port: p.port, + proxy_type: p.proxy_type, + username: p.username, + password: p.password, + country: p.country, + city: p.city, + status: p.status, + latency: p.latency, + last_check_ip: p.last_check_ip, + }, + ) + }) + .collect(); + + // 8. 批量查询标签信息 + let tag_rows = if !env_uuids.is_empty() { + models::fetch_tags_for_environments(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())? + } else { + vec![] + }; + let mut env_tags_map: HashMap> = HashMap::new(); + for tag_row in tag_rows { + env_tags_map.entry(tag_row.environment_uuid).or_default().push(TagDto { + id: tag_row.tag_id, + uuid: tag_row.tag_uuid, + user_uuid: tag_row.tag_user_uuid, + team_uuid: tag_row.tag_team_uuid, + name: tag_row.tag_name, + color: tag_row.tag_color, + sort_order: tag_row.tag_sort_order, + environments_count: tag_row.tag_environments_count, + created_at: tag_row.tag_created_at, + updated_at: tag_row.tag_updated_at, + deleted_at: tag_row.tag_deleted_at, + }); + } + + let mut env_urls_map: HashMap> = HashMap::new(); + let url_rows = models::fetch_environment_urls_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + for url in url_rows { + env_urls_map.entry(url.environment_uuid).or_default().push(url); + } + + let mut env_cookies_map: HashMap> = HashMap::new(); + let cookie_rows = models::fetch_environment_cookies_by_uuids(&svc_ctx.db, &env_uuids) + .await + .map_err(|e| e.to_string())?; + let mut grouped_cookie_rows: HashMap> = HashMap::new(); + for cookie in cookie_rows { + grouped_cookie_rows.entry(cookie.environment_uuid).or_default().push(cookie); + } + for (environment_uuid, rows) in grouped_cookie_rows { + env_cookies_map.insert(environment_uuid, group_cookie_rows(rows)); + } + + // 9. 批量查询账号信息 + let mut env_accounts_map: HashMap> = HashMap::new(); + for env_uuid in &env_uuids { + let accounts = accounts::get_environment_accounts_service(svc_ctx, *env_uuid) + .await + .unwrap_or_default(); + if !accounts.is_empty() { + env_accounts_map.insert(*env_uuid, accounts); + } + } + + // 10. 组装完整的环境信息 + let environments: Vec = env_rows + .into_iter() + .map(|row| { + let group = row.group_uuid.and_then(|gid| group_map.get(&gid).cloned()); + let proxy = row.proxy_uuid.and_then(|pid| proxy_map.get(&pid).cloned()); + let tags = env_tags_map.get(&row.uuid).cloned().unwrap_or_default(); + let accounts = env_accounts_map.get(&row.uuid).cloned().unwrap_or_default(); + + crate::entitys::EnvironmentDetailResponse { + environment: EnvironmentDto { + id: row.id, + uuid: row.uuid, + workspace_uuid: row.workspace_uuid, + user_uuid: row.user_uuid, + team_uuid: row.team_uuid, + name: row.name, + description: row.description, + status: row.status, + group_uuid: row.group_uuid, + proxy_uuid: row.proxy_uuid, + system_info: row.system_info, + kernel_info: row.kernel_info, + fingerprint_summary: row.fingerprint_summary, + last_opened_at: row.last_opened_at, + created_at: row.created_at, + updated_at: row.updated_at, + deleted_at: None, + }, + config: None, + cookies: env_cookies_map.remove(&row.uuid).unwrap_or_default(), + urls: env_urls_map.remove(&row.uuid).unwrap_or_default(), + tags, + accounts, + group, + proxy, + extensions: vec![], // 回收站不需要返回扩展数据 + } + }) + .collect(); + + Ok((environments, total_count)) +} + +/// 恢复环境 +pub async fn restore_environment_service(svc_ctx: &SvcCtx, env_uuid: Uuid) -> Result<(), String> { + models::restore_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量恢复环境 +pub async fn batch_restore_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], +) -> Result { + models::batch_restore_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// 永久删除环境 +pub async fn permanent_delete_environment_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result<(), String> { + // 永久删除环境 + models::permanent_delete_environment(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新工作空间配额(永久删除后减少使用数) + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 批量永久删除环境 +pub async fn batch_permanent_delete_environments_service( + svc_ctx: &SvcCtx, + env_uuids: &[Uuid], + workspace_uuid: Uuid, +) -> Result { + let count = models::batch_permanent_delete_environments(&svc_ctx.db, env_uuids) + .await + .map_err(|e| e.to_string())?; + + // 更新工作空间配额 + if count > 0 { + models::decrement_used_environments(&svc_ctx.db, workspace_uuid, count as i32) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + } + + Ok(count) +} + +// ============ Environment URLs ============ + +/// 添加环境 URL +pub async fn add_environment_url_service( + svc_ctx: &SvcCtx, + payload: &AddEnvironmentUrlRequest, +) -> Result { + models::insert_environment_url( + &svc_ctx.db, + payload.environment_uuid, + &payload.url, + payload.title.as_deref(), + payload.sort_order, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取环境的所有 URL +pub async fn get_environment_urls_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + models::fetch_environment_urls(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 删除环境 URL +pub async fn delete_environment_url_service(svc_ctx: &SvcCtx, url_id: i32) -> Result<(), String> { + models::delete_environment_url(&svc_ctx.db, url_id) + .await + .map_err(|e| e.to_string()) +} + +/// 清空环境的所有 URL +pub async fn clear_environment_urls_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::clear_environment_urls(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +// ============ Environment Cookies ============ + +/// 添加环境 Cookie +pub async fn add_environment_cookie_service( + svc_ctx: &SvcCtx, + payload: &AddEnvironmentCookieRequest, +) -> Result { + let cookies = parse_cookie_group(&CookieGroupInput { + site: payload.site.clone(), + cookie_text: payload.cookie_text.clone(), + })?; + + let mut last_id = 0; + for cookie in cookies { + last_id = models::insert_environment_cookie( + &svc_ctx.db, + payload.environment_uuid, + &cookie.site_input, + &cookie.domain, + &cookie.name, + &cookie.value, + cookie.path.as_deref(), + cookie.http_only, + cookie.secure, + cookie.same_site.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(last_id) +} + +/// 获取环境的所有 Cookies +pub async fn get_environment_cookies_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result, String> { + let rows = models::fetch_environment_cookies(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok(group_cookie_rows(rows)) +} + +/// 删除环境 Cookie +pub async fn delete_environment_cookie_service( + svc_ctx: &SvcCtx, + cookie_id: i32, +) -> Result<(), String> { + models::delete_environment_cookie(&svc_ctx.db, cookie_id) + .await + .map_err(|e| e.to_string()) +} + +/// 清空环境的所有 Cookies +pub async fn clear_environment_cookies_service( + svc_ctx: &SvcCtx, + env_uuid: Uuid, +) -> Result { + models::clear_environment_cookies(&svc_ctx.db, env_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量创建环境 +pub async fn batch_create_environments_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &BatchCreateEnvironmentRequest, +) -> Result, String> { + let mut created_uuids = Vec::new(); + + for env_request in &payload.environments { + let env_uuid = + create_environment_service(svc_ctx, user_uuid, workspace_uuid, team_uuid, env_request) + .await + .map_err(|e| format!("创建环境 '{}' 失败: {}", env_request.name, e))?; + + created_uuids.push(env_uuid); + } + + Ok(created_uuids) +} diff --git a/src-tauri/crates/business/src/services/group_permissions.rs b/src-tauri/crates/business/src/services/group_permissions.rs new file mode 100644 index 00000000..42d84645 --- /dev/null +++ b/src-tauri/crates/business/src/services/group_permissions.rs @@ -0,0 +1,160 @@ +use uuid::Uuid; + +use crate::dto::GroupMemberPermissionDetailDto; +use crate::entitys::{ + CheckGroupPermissionRequest, GrantGroupPermissionRequest, ListUserGroupPermissionsRequest, + RevokeGroupPermissionRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 授予分组权限 +pub async fn grant_group_permission_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &GrantGroupPermissionRequest, +) -> Result<(), String> { + // 检查权限:只有团队 Owner/Admin 或拥有分组 manage 权限的用户可以授权 + let group = models::fetch_group_by_uuid(&svc_ctx.db, payload.group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 检查用户是否是团队成员(工作空间级别) + let team_member = models::fetch_team_member( + &svc_ctx.db, + group.workspace_uuid, + group.team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // Owner/Admin 自动拥有所有权限 + let can_grant = team_member.role == "owner" + || team_member.role == "admin" + || models::check_group_permission( + &svc_ctx.db, + group.workspace_uuid, + payload.group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !can_grant { + return Err("您没有权限授予分组权限".to_string()); + } + + models::grant_group_permission( + &svc_ctx.db, + payload.group_uuid, + group.workspace_uuid, + group.team_uuid, + payload.user_uuid, + &payload.permission_type, + user_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 撤销分组权限 +pub async fn revoke_group_permission_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &RevokeGroupPermissionRequest, +) -> Result<(), String> { + // 检查权限:只有团队 Owner/Admin 或拥有分组 manage 权限的用户可以撤销权限 + let group = models::fetch_group_by_uuid(&svc_ctx.db, payload.group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 检查用户是否是团队成员(工作空间级别) + let team_member = models::fetch_team_member( + &svc_ctx.db, + group.workspace_uuid, + group.team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // Owner/Admin 自动拥有所有权限 + let can_revoke = team_member.role == "owner" + || team_member.role == "admin" + || models::check_group_permission( + &svc_ctx.db, + group.workspace_uuid, + payload.group_uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())?; + + if !can_revoke { + return Err("您没有权限撤销分组权限".to_string()); + } + + models::revoke_group_permission(&svc_ctx.db, payload.group_uuid, payload.user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 检查分组权限 +pub async fn check_group_permission_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + payload: &CheckGroupPermissionRequest, +) -> Result { + models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + payload.group_uuid, + payload.user_uuid, + &payload.permission_type, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 查询用户的分组权限列表 +pub async fn list_user_group_permissions_service( + svc_ctx: &SvcCtx, + payload: &ListUserGroupPermissionsRequest, +) -> Result, String> { + let permissions = models::fetch_user_group_permissions( + &svc_ctx.db, + payload.user_uuid, + None, // workspace_uuid 可以从 payload 中获取,如果需要的话 + payload.group_uuid, + ) + .await + .map_err(|e| e.to_string())?; + let offset = ((payload.pagination.page - 1) * payload.pagination.page_size).max(0) as usize; + let limit = payload.pagination.page_size.max(0) as usize; + let mut details = Vec::new(); + for permission in permissions.into_iter().skip(offset).take(limit) { + let user_info = models::user::fetch_user_info_by_uuid(&svc_ctx.db, permission.user_uuid) + .await + .map_err(|error| error.to_string())?; + details.push(GroupMemberPermissionDetailDto { + group_uuid: permission.group_uuid, + workspace_uuid: permission.workspace_uuid, + team_uuid: permission.team_uuid, + user_uuid: permission.user_uuid, + permission_type: permission.permission_type, + granted_by: permission.granted_by, + user_name: user_info.as_ref().and_then(|user| user.nickname.clone()), + user_email: user_info.map(|user| user.email), + created_at: permission.created_at, + updated_at: permission.updated_at, + }); + } + Ok(details) +} diff --git a/src-tauri/crates/business/src/services/groups.rs b/src-tauri/crates/business/src/services/groups.rs new file mode 100644 index 00000000..fefbf3da --- /dev/null +++ b/src-tauri/crates/business/src/services/groups.rs @@ -0,0 +1,158 @@ +use uuid::Uuid; + +use crate::dto::GroupDto; +use crate::entitys::{CreateGroupRequest, UpdateGroupRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建分组 +pub async fn create_group_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &CreateGroupRequest, +) -> Result { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 检查用户是否是团队 Owner/Admin(只有 Owner/Admin 可以创建分组) + let can_create = matches!(team_member.role.as_str(), "owner" | "admin"); + if !can_create { + return Err("您没有创建分组的权限,需要 Owner 或 Admin 角色".to_string()); + } + + models::insert_group( + &svc_ctx.db, + workspace_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + user_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取分组列表 +pub async fn get_groups_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + page: i64, + page_size: i64, +) -> Result, String> { + let offset = (page - 1) * page_size; + models::fetch_groups(&svc_ctx.db, workspace_uuid, team_uuid, offset, page_size) + .await + .map_err(|e| e.to_string()) +} + +/// 获取分组详情 +pub async fn get_group_service(svc_ctx: &SvcCtx, group_uuid: Uuid) -> Result { + models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string()) +} + +/// 更新分组 +pub async fn update_group_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &UpdateGroupRequest, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询分组 + let group = models::fetch_group_by_uuid(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 3. 验证分组属于指定工作空间和团队 + if group.workspace_uuid != workspace_uuid || group.team_uuid != team_uuid { + return Err("分组不属于指定工作空间或团队".to_string()); + } + + // 4. 检查用户是否是团队 Owner/Admin 或拥有分组的 manage 权限 + let is_owner_or_admin = matches!(team_member.role.as_str(), "owner" | "admin"); + let has_manage = if !is_owner_or_admin { + models::check_group_permission( + &svc_ctx.db, + workspace_uuid, + payload.uuid, + user_uuid, + "manage", + ) + .await + .map_err(|e| e.to_string())? + } else { + true + }; + + if !has_manage { + return Err("您没有管理该分组的权限".to_string()); + } + + models::update_group( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.sort_order, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除分组 +pub async fn delete_group_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + group_uuid: Uuid, +) -> Result<(), String> { + // 1. 检查用户是否在当前工作空间的团队中 + let team_member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 2. 查询分组 + let group = models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "分组不存在".to_string())?; + + // 3. 验证分组属于指定工作空间和团队 + if group.workspace_uuid != workspace_uuid || group.team_uuid != team_uuid { + return Err("分组不属于指定工作空间或团队".to_string()); + } + + // 4. 检查用户是否是团队 Owner/Admin 或拥有分组的 manage 权限 + let is_owner_or_admin = matches!(team_member.role.as_str(), "owner" | "admin"); + let has_manage = if !is_owner_or_admin { + models::check_group_permission(&svc_ctx.db, workspace_uuid, group_uuid, user_uuid, "manage") + .await + .map_err(|e| e.to_string())? + } else { + true + }; + + if !has_manage { + return Err("您没有删除该分组的权限".to_string()); + } + + models::delete_group(&svc_ctx.db, group_uuid).await.map_err(|e| e.to_string()) +} diff --git a/src-tauri/crates/business/src/services/local_api.rs b/src-tauri/crates/business/src/services/local_api.rs new file mode 100644 index 00000000..dbfcbfa0 --- /dev/null +++ b/src-tauri/crates/business/src/services/local_api.rs @@ -0,0 +1,233 @@ +use chrono::{Timelike, Utc}; +use uuid::Uuid; + +use crate::dto::{LocalApiConfigDto, ResetLocalApiKeyDto, ValidateLocalApiKeyDto}; +use crate::entitys::{UpdateLocalApiConfigRequest, ValidateLocalApiKeyRequest}; +use crate::{models, svc_ctx::SvcCtx}; + +const DEFAULT_DAILY_LIMIT: i32 = 1000; + +pub async fn get_local_api_config_service( + context: &SvcCtx, + user_uuid: Uuid, +) -> Result { + init_local_api_for_user_service(context, user_uuid).await?; + let settings = models::local_api::fetch_local_api_settings(&context.db, user_uuid) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Local API settings do not exist".to_string())?; + let api_key = models::local_api::fetch_active_api_key(&context.db, user_uuid) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Local API key does not exist".to_string())?; + + Ok(LocalApiConfigDto { + enabled: settings.enabled, + api_key: api_key.api_key.ok_or_else(|| "Local API key is unavailable".to_string())?, + port: settings.port, + remote_access: settings.remote_access, + cors_origins: models::local_api::parse_cors_origins(&settings.cors_origins), + requests_today: api_key.requests_today, + daily_limit: api_key.daily_limit, + }) +} + +pub async fn update_local_api_config_service( + context: &SvcCtx, + user_uuid: Uuid, + request: &UpdateLocalApiConfigRequest, +) -> Result { + if let Some(port) = request.port { + if !(1..=65535).contains(&port) { + return Err("Local API port must be between 1 and 65535".to_string()); + } + } + + init_local_api_for_user_service(context, user_uuid).await?; + let cors_origins = request + .cors_origins + .as_ref() + .map(|origins| models::local_api::build_cors_origins_value(origins)); + models::local_api::upsert_local_api_settings( + &context.db, + user_uuid, + request.enabled, + request.port, + request.remote_access, + cors_origins.as_ref(), + ) + .await + .map_err(|error| error.to_string())?; + + get_local_api_config_service(context, user_uuid).await +} + +pub async fn reset_local_api_key_service( + context: &SvcCtx, + user_uuid: Uuid, +) -> Result { + init_local_api_for_user_service(context, user_uuid).await?; + models::local_api::deactivate_api_keys_for_user(&context.db, user_uuid) + .await + .map_err(|error| error.to_string())?; + let (api_key, key_id) = create_api_key(context, user_uuid).await?; + ensure_default_permissions(context, key_id).await?; + Ok(ResetLocalApiKeyDto { api_key }) +} + +pub async fn validate_local_api_key_service( + context: &SvcCtx, + request: &ValidateLocalApiKeyRequest, +) -> Result { + let key_hash = models::local_api::hash_api_key(&request.api_key); + let mut api_key = models::local_api::fetch_api_key_by_hash(&context.db, &key_hash) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Invalid Local API key".to_string())?; + let now = Utc::now(); + if api_key.expires_at.is_some_and(|expires_at| expires_at < now) { + return Err("Local API key has expired".to_string()); + } + if api_key.last_reset_date != now.date_naive() { + models::local_api::reset_api_key_daily_usage(&context.db, api_key.id, now.date_naive()) + .await + .map_err(|error| error.to_string())?; + api_key.requests_today = 0; + } + if api_key.requests_today >= api_key.daily_limit { + return Err("Local API daily limit reached".to_string()); + } + + let definition = + models::local_api::fetch_permission_definition(&context.db, &request.permission_code) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Unknown Local API permission".to_string())?; + let permission = models::local_api::fetch_api_key_permission( + &context.db, + api_key.id, + &request.permission_code, + ) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "Local API key cannot access this route".to_string())?; + if !permission.is_enabled { + return Err("Local API permission is disabled".to_string()); + } + + let minute_start = now.with_second(0).and_then(|value| value.with_nanosecond(0)).unwrap_or(now); + let hour_start = minute_start.with_minute(0).unwrap_or(minute_start); + let minute_count = models::local_api::fetch_request_count( + &context.db, + api_key.id, + &request.permission_code, + "minute", + minute_start, + ) + .await + .map_err(|error| error.to_string())?; + let hour_count = models::local_api::fetch_request_count( + &context.db, + api_key.id, + &request.permission_code, + "hour", + hour_start, + ) + .await + .map_err(|error| error.to_string())?; + if minute_count >= permission.rate_limit_per_minute { + return Err("Local API minute rate limit reached".to_string()); + } + if hour_count >= permission.rate_limit_per_hour { + return Err("Local API hourly rate limit reached".to_string()); + } + + models::local_api::increment_request_counter( + &context.db, + api_key.id, + &request.permission_code, + "minute", + minute_start, + ) + .await + .map_err(|error| error.to_string())?; + models::local_api::increment_request_counter( + &context.db, + api_key.id, + &request.permission_code, + "hour", + hour_start, + ) + .await + .map_err(|error| error.to_string())?; + models::local_api::increment_api_key_usage(&context.db, api_key.id, now) + .await + .map_err(|error| error.to_string())?; + + Ok(ValidateLocalApiKeyDto { + valid: true, + user_uuid: api_key.user_uuid, + permission_code: definition.permission_code, + requests_today: api_key.requests_today + 1, + daily_limit: api_key.daily_limit, + rate_limit_per_minute: permission.rate_limit_per_minute, + rate_limit_per_hour: permission.rate_limit_per_hour, + }) +} + +pub async fn init_local_api_for_user_service( + context: &SvcCtx, + user_uuid: Uuid, +) -> Result<(), String> { + models::local_api::upsert_local_api_settings(&context.db, user_uuid, None, None, None, None) + .await + .map_err(|error| error.to_string())?; + if let Some(key) = models::local_api::fetch_active_api_key(&context.db, user_uuid) + .await + .map_err(|error| error.to_string())? + { + if key.api_key.is_some() { + return ensure_default_permissions(context, key.id).await; + } + models::local_api::deactivate_api_keys_for_user(&context.db, user_uuid) + .await + .map_err(|error| error.to_string())?; + } + let (_, key_id) = create_api_key(context, user_uuid).await?; + ensure_default_permissions(context, key_id).await +} + +async fn create_api_key(context: &SvcCtx, user_uuid: Uuid) -> Result<(String, i32), String> { + let api_key = format!("sk_local_{}", Uuid::new_v4().simple()); + let key_hash = models::local_api::hash_api_key(&api_key); + let key_prefix = api_key.chars().take(16).collect::(); + let created = models::local_api::insert_api_key( + &context.db, + user_uuid, + &key_prefix, + &key_hash, + &api_key, + DEFAULT_DAILY_LIMIT, + ) + .await + .map_err(|error| error.to_string())?; + Ok((api_key, created.id)) +} + +async fn ensure_default_permissions(context: &SvcCtx, api_key_id: i32) -> Result<(), String> { + for definition in models::local_api::fetch_permission_definitions(&context.db) + .await + .map_err(|error| error.to_string())? + { + models::local_api::insert_api_key_permission( + &context.db, + api_key_id, + &definition.permission_code, + definition.default_rate_limit_per_minute, + definition.default_rate_limit_per_hour, + ) + .await + .map_err(|error| error.to_string())?; + } + Ok(()) +} diff --git a/src-tauri/crates/business/src/services/local_users.rs b/src-tauri/crates/business/src/services/local_users.rs new file mode 100644 index 00000000..66befeb4 --- /dev/null +++ b/src-tauri/crates/business/src/services/local_users.rs @@ -0,0 +1,422 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::{dto::LocalApiPermissionDefinitionDto, svc_ctx::SvcCtx}; + +const PASSWORD_HASH_ROUNDS: u32 = 100_000; +pub const LOCAL_USER_AVATARS: &[&str] = &[ + "🙂", "😎", "🦊", "🐼", "🐯", "🐙", "🦉", "🐳", "🌙", "⭐", "🌿", "🚀", +]; + +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +#[serde(rename_all = "camelCase")] +pub struct LocalUser { + pub uuid: Uuid, + pub nickname: String, + pub avatar: String, + pub has_password: bool, + pub current_workspace_uuid: Option, + pub current_team_uuid: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateLocalUserRequest { + pub nickname: String, + pub avatar: String, + pub password: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginLocalUserRequest { + pub user_uuid: Uuid, + pub password: Option, +} + +pub async fn list_local_users(context: &SvcCtx) -> Result, String> { + sqlx::query_as::<_, LocalUser>( + r#" + SELECT + u.uuid, + COALESCE(NULLIF(ui.nickname, ''), 'Local User') AS nickname, + lua.avatar, + (lua.password_hash IS NOT NULL) AS has_password, + ui.current_workspace_uuid, + ui.current_team_uuid + FROM users u + JOIN user_infos ui ON ui.user_uuid = u.uuid + JOIN local_user_auth lua ON lua.user_uuid = u.uuid + WHERE u.deleted_at IS NULL AND ui.deleted_at IS NULL + ORDER BY lua.created_at, ui.id + "#, + ) + .fetch_all(&context.db) + .await + .map_err(|error| error.to_string()) +} + +pub async fn current_local_user(context: &SvcCtx) -> Result, String> { + let Some(user_uuid) = context.current_user_uuid() else { + return Ok(None); + }; + fetch_local_user(context, user_uuid).await +} + +pub async fn create_local_user( + context: &SvcCtx, + request: &CreateLocalUserRequest, +) -> Result { + let nickname = validate_nickname(&request.nickname)?; + let avatar = validate_avatar(&request.avatar)?; + let password = normalize_password(request.password.as_deref()); + let password_credentials = password.map(create_password_credentials); + let permission_definitions = + crate::models::local_api::fetch_permission_definitions(&context.db) + .await + .map_err(|error| error.to_string())?; + let mut transaction = context.db.begin().await.map_err(|error| error.to_string())?; + let user_uuid = Uuid::new_v4(); + let user_id = format!("LOCAL-{}", user_uuid.simple()); + + sqlx::query("INSERT INTO users (uuid, id) VALUES ($1, $2)") + .bind(user_uuid) + .bind(user_id) + .execute(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + // `email` and `password` are legacy columns retained while the copied business schema is + // being reduced. They are not local-account attributes and are never exposed to the UI. + sqlx::query( + "INSERT INTO user_infos (user_uuid, nickname, email, password, avatar_hash, status) \ + VALUES ($1, $2, $3, '', $4, 'active')", + ) + .bind(user_uuid) + .bind(&nickname) + .bind(format!("local-{user_uuid}@simprint.invalid")) + .bind(&avatar) + .execute(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + let (password_salt, password_hash) = password_credentials + .map(|(salt, hash)| (Some(salt), Some(hash))) + .unwrap_or((None, None)); + sqlx::query( + "INSERT INTO local_user_auth (user_uuid, avatar, password_salt, password_hash) \ + VALUES ($1, $2, $3, $4)", + ) + .bind(user_uuid) + .bind(&avatar) + .bind(password_salt) + .bind(password_hash) + .execute(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + sqlx::query( + "INSERT INTO user_preferences (user_uuid, theme, language, notifications_enabled) \ + VALUES ($1, 'system', 'zh-CN', TRUE)", + ) + .bind(user_uuid) + .execute(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + sqlx::query( + "INSERT INTO user_local_api_settings \ + (user_uuid, enabled, port, remote_access, cors_origins) \ + VALUES ($1, FALSE, 8080, FALSE, '[]')", + ) + .bind(user_uuid) + .execute(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + initialize_local_api_key(&mut transaction, user_uuid, &permission_definitions).await?; + + let workspace_uuid: Uuid = sqlx::query_scalar( + "INSERT INTO workspaces (name, owner_uuid, workspace_type) \ + VALUES ($1, $2, 'personal') RETURNING uuid", + ) + .bind(format!("{nickname} 的工作空间")) + .bind(user_uuid) + .fetch_one(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + let quota = &context.workspace_quota.default; + sqlx::query( + "INSERT INTO workspace_quotas \ + (workspace_uuid, max_environments, max_team_members, max_proxies, max_rpa_tasks) \ + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(workspace_uuid) + .bind(quota.max_environments) + .bind(quota.max_team_members) + .bind(quota.max_proxies) + .bind(quota.max_rpa_tasks) + .execute(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + let team_uuid: Uuid = sqlx::query_scalar( + "INSERT INTO teams (workspace_uuid, name, description, owner_uuid) \ + VALUES ($1, $2, '默认团队', $3) RETURNING uuid", + ) + .bind(workspace_uuid) + .bind(format!("{nickname} 的团队")) + .bind(user_uuid) + .fetch_one(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + sqlx::query( + "INSERT INTO team_members (team_uuid, workspace_uuid, user_uuid, role, status) \ + VALUES ($1, $2, $3, 'owner', 'active')", + ) + .bind(team_uuid) + .bind(workspace_uuid) + .bind(user_uuid) + .execute(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + sqlx::query( + "UPDATE user_infos SET current_workspace_uuid = $1, current_team_uuid = $2 \ + WHERE user_uuid = $3", + ) + .bind(workspace_uuid) + .bind(team_uuid) + .bind(user_uuid) + .execute(&mut *transaction) + .await + .map_err(|error| error.to_string())?; + + transaction.commit().await.map_err(|error| error.to_string())?; + fetch_local_user(context, user_uuid) + .await? + .ok_or_else(|| "本地用户创建后无法读取".to_string()) +} + +pub async fn authenticate_local_user( + context: &SvcCtx, + request: &LoginLocalUserRequest, +) -> Result { + verify_local_user_password(context, request.user_uuid, request.password.as_deref()).await?; + + let user = fetch_local_user(context, request.user_uuid) + .await? + .ok_or_else(|| "本地用户不存在".to_string())?; + context.authenticate_user(user.uuid); + Ok(user) +} + +pub async fn verify_local_user_password( + context: &SvcCtx, + user_uuid: Uuid, + password: Option<&str>, +) -> Result<(), String> { + let credentials = sqlx::query_as::<_, (Option, Option)>( + "SELECT password_salt, password_hash FROM local_user_auth WHERE user_uuid = $1", + ) + .bind(user_uuid) + .fetch_optional(&context.db) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "本地用户不存在".to_string())?; + + match credentials { + (None, None) => {} + (Some(salt), Some(expected_hash)) => { + let password = password.unwrap_or_default(); + let actual_hash = derive_password_hash(password, &salt); + if !constant_time_equal(actual_hash.as_bytes(), expected_hash.as_bytes()) { + return Err("密码不正确".to_string()); + } + } + _ => return Err("本地用户密码状态无效".to_string()), + } + Ok(()) +} + +async fn fetch_local_user(context: &SvcCtx, user_uuid: Uuid) -> Result, String> { + sqlx::query_as::<_, LocalUser>( + r#" + SELECT + u.uuid, + COALESCE(NULLIF(ui.nickname, ''), 'Local User') AS nickname, + lua.avatar, + (lua.password_hash IS NOT NULL) AS has_password, + ui.current_workspace_uuid, + ui.current_team_uuid + FROM users u + JOIN user_infos ui ON ui.user_uuid = u.uuid + JOIN local_user_auth lua ON lua.user_uuid = u.uuid + WHERE u.uuid = $1 AND u.deleted_at IS NULL AND ui.deleted_at IS NULL + "#, + ) + .bind(user_uuid) + .fetch_optional(&context.db) + .await + .map_err(|error| error.to_string()) +} + +async fn initialize_local_api_key( + transaction: &mut sqlx::Transaction<'_, crate::database::Db>, + user_uuid: Uuid, + definitions: &[LocalApiPermissionDefinitionDto], +) -> Result<(), String> { + let api_key = format!("sk_local_{}", Uuid::new_v4().simple()); + let key_hash = crate::models::local_api::hash_api_key(&api_key); + let key_id: i32 = sqlx::query_scalar( + "INSERT INTO user_local_api_keys \ + (user_uuid, key_prefix, key_hash, api_key, daily_limit) \ + VALUES ($1, $2, $3, $4, 1000) RETURNING id", + ) + .bind(user_uuid) + .bind(api_key.chars().take(16).collect::()) + .bind(key_hash) + .bind(api_key) + .fetch_one(&mut **transaction) + .await + .map_err(|error| error.to_string())?; + + for definition in definitions { + sqlx::query( + "INSERT INTO user_local_api_key_permissions \ + (api_key_id, permission_code, is_enabled, rate_limit_per_minute, rate_limit_per_hour) \ + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(key_id) + .bind(&definition.permission_code) + .bind(definition.default_enabled) + .bind(definition.default_rate_limit_per_minute) + .bind(definition.default_rate_limit_per_hour) + .execute(&mut **transaction) + .await + .map_err(|error| error.to_string())?; + } + Ok(()) +} + +fn validate_nickname(value: &str) -> Result { + let nickname = value.trim(); + if nickname.is_empty() { + return Err("请输入用户昵称".to_string()); + } + if nickname.chars().count() > 64 { + return Err("用户昵称不能超过 64 个字符".to_string()); + } + Ok(nickname.to_string()) +} + +fn validate_avatar(value: &str) -> Result { + if LOCAL_USER_AVATARS.contains(&value) { + Ok(value.to_string()) + } else { + Err("请选择有效的本地用户图标".to_string()) + } +} + +fn normalize_password(value: Option<&str>) -> Option<&str> { + value.filter(|password| !password.is_empty()) +} + +fn create_password_credentials(password: &str) -> (String, String) { + let salt = Uuid::new_v4().simple().to_string(); + let hash = derive_password_hash(password, &salt); + (salt, hash) +} + +fn derive_password_hash(password: &str, salt: &str) -> String { + let mut state = Sha256::digest([salt.as_bytes(), password.as_bytes()].concat()).to_vec(); + for round in 0..PASSWORD_HASH_ROUNDS { + let mut hasher = Sha256::new(); + hasher.update(&state); + hasher.update(salt.as_bytes()); + hasher.update(password.as_bytes()); + hasher.update(round.to_le_bytes()); + state = hasher.finalize().to_vec(); + } + hex::encode(state) +} + +fn constant_time_equal(left: &[u8], right: &[u8]) -> bool { + if left.len() != right.len() { + return false; + } + left.iter().zip(right).fold(0_u8, |difference, (left, right)| { + difference | (left ^ right) + }) == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::DatabaseConfig; + + #[tokio::test] + async fn creates_and_authenticates_password_and_passwordless_users() { + let mut config = DatabaseConfig::embedded("sqlite::memory:"); + config.max_connections = 1; + config.min_connections = 1; + let context = SvcCtx::new(&config).await.unwrap(); + + let passwordless = create_local_user( + &context, + &CreateLocalUserRequest { + nickname: "Alice".to_string(), + avatar: "🦊".to_string(), + password: None, + }, + ) + .await + .unwrap(); + assert!(!passwordless.has_password); + authenticate_local_user( + &context, + &LoginLocalUserRequest { + user_uuid: passwordless.uuid, + password: None, + }, + ) + .await + .unwrap(); + + let protected = create_local_user( + &context, + &CreateLocalUserRequest { + nickname: "Bob".to_string(), + avatar: "🚀".to_string(), + password: Some("secret".to_string()), + }, + ) + .await + .unwrap(); + assert!(protected.has_password); + assert!( + authenticate_local_user( + &context, + &LoginLocalUserRequest { + user_uuid: protected.uuid, + password: Some("wrong".to_string()), + }, + ) + .await + .is_err() + ); + authenticate_local_user( + &context, + &LoginLocalUserRequest { + user_uuid: protected.uuid, + password: Some("secret".to_string()), + }, + ) + .await + .unwrap(); + assert_eq!(context.current_user_uuid(), Some(protected.uuid)); + } +} diff --git a/src-tauri/crates/business/src/services/messages.rs b/src-tauri/crates/business/src/services/messages.rs new file mode 100644 index 00000000..a5cf2f57 --- /dev/null +++ b/src-tauri/crates/business/src/services/messages.rs @@ -0,0 +1,187 @@ +use uuid::Uuid; + +// DTOs are used through entitys +use crate::entitys::{ + BatchMarkReadRequest, CreateMessageRequest, HandleMessageRequest, ListMessagesRequest, + MarkMessageReadRequest, MessageListResponse, MessageStatsResponse, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建消息 +pub async fn create_message_service( + svc_ctx: &SvcCtx, + sender_uuid: Option, + payload: &CreateMessageRequest, +) -> Result { + let priority = payload.priority.as_deref().unwrap_or("normal"); + + // 创建消息 + let message_uuid = models::create_message( + &svc_ctx.db, + sender_uuid, + &payload.message_type, + &payload.title, + payload.content.as_deref(), + &payload.recipient_type, + payload.related_type.as_deref(), + payload.related_uuid, + priority, + payload.metadata.clone(), + ) + .await + .map_err(|e| e.to_string())?; + + // 根据接收者类型添加接收者 + match payload.recipient_type.as_str() { + "single" | "multiple" => { + if payload.recipient_uuids.is_empty() { + return Err("接收者列表不能为空".to_string()); + } + + // 对于邀请类消息,设置 action_status 为 pending + let action_status = if payload.message_type == "team_invitation" { + Some("pending") + } else { + None + }; + + models::add_message_recipients( + &svc_ctx.db, + message_uuid, + &payload.recipient_uuids, + action_status, + ) + .await + .map_err(|e| e.to_string())?; + } + "team" => { + // 团队消息由数据库触发器自动分发,无需手动添加 + if payload.related_type.as_deref() != Some("team") || payload.related_uuid.is_none() { + return Err("团队消息必须指定 related_type='team' 和 related_uuid".to_string()); + } + } + "all" => { + // 系统广播消息,需要为所有用户创建关联记录 + // 这里可以通过应用层逻辑实现,或者使用数据库触发器 + // 暂时返回错误,提示需要特殊处理 + return Err("系统广播消息暂不支持".to_string()); + } + _ => { + return Err(format!("不支持的接收者类型: {}", payload.recipient_type)); + } + } + + Ok(message_uuid) +} + +/// 获取用户消息列表 +pub async fn get_user_messages_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListMessagesRequest, +) -> Result { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let filters = payload.filters.as_ref(); + + let messages = models::fetch_user_messages( + &svc_ctx.db, + user_uuid, + offset, + payload.pagination.page_size, + filters.and_then(|f| f.message_type.as_deref()), + filters.and_then(|f| f.is_read), + filters.and_then(|f| f.action_status.as_deref()), + filters.and_then(|f| f.priority.as_deref()), + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_user_messages_count( + &svc_ctx.db, + user_uuid, + filters.and_then(|f| f.message_type.as_deref()), + filters.and_then(|f| f.is_read), + filters.and_then(|f| f.action_status.as_deref()), + filters.and_then(|f| f.priority.as_deref()), + ) + .await + .map_err(|e| e.to_string())?; + + Ok(MessageListResponse { + items: messages, + total, + page: payload.pagination.page, + page_size: payload.pagination.page_size, + }) +} + +/// 标记消息为已读 +pub async fn mark_message_read_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &MarkMessageReadRequest, +) -> Result<(), String> { + models::mark_message_read(&svc_ctx.db, payload.message_uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量标记消息为已读 +pub async fn batch_mark_messages_read_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &BatchMarkReadRequest, +) -> Result<(), String> { + if payload.message_uuids.is_empty() { + return Err("消息列表不能为空".to_string()); + } + + models::batch_mark_messages_read(&svc_ctx.db, &payload.message_uuids, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 处理消息(接受/拒绝) +pub async fn handle_message_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &HandleMessageRequest, +) -> Result<(), String> { + if payload.action != "accept" && payload.action != "reject" { + return Err("操作类型必须是 'accept' 或 'reject'".to_string()); + } + + models::handle_message( + &svc_ctx.db, + payload.message_uuid, + user_uuid, + &payload.action, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取用户消息统计 +pub async fn get_user_message_stats_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + let (total, unread, by_type) = models::fetch_user_message_stats(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + Ok(MessageStatsResponse { + total, + unread, + by_type, + }) +} + +/// 删除消息 +pub async fn delete_message_service(svc_ctx: &SvcCtx, message_uuid: Uuid) -> Result<(), String> { + models::delete_message(&svc_ctx.db, message_uuid) + .await + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/crates/business/src/services/preferences.rs b/src-tauri/crates/business/src/services/preferences.rs new file mode 100644 index 00000000..eb08873f --- /dev/null +++ b/src-tauri/crates/business/src/services/preferences.rs @@ -0,0 +1,49 @@ +use uuid::Uuid; + +use crate::dto::UserPreferenceDto; +use crate::entitys::settings::UpdatePreferencesRequest; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 获取用户偏好设置 +pub async fn get_preferences_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result { + let preferences = models::preferences::fetch_user_preferences(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 如果不存在,创建默认设置 + if preferences.is_none() { + models::preferences::upsert_user_preferences(&svc_ctx.db, user_uuid, None, None, None) + .await + .map_err(|e| e.to_string())?; + + return models::preferences::fetch_user_preferences(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "创建偏好设置失败".to_string()); + } + + preferences.ok_or_else(|| "偏好设置不存在".to_string()) +} + +/// 更新用户偏好设置 +pub async fn update_preferences_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdatePreferencesRequest, +) -> Result { + models::preferences::upsert_user_preferences( + &svc_ctx.db, + user_uuid, + payload.theme.as_deref(), + payload.language.as_deref(), + payload.notifications_enabled, + ) + .await + .map_err(|e| e.to_string())?; + + get_preferences_service(svc_ctx, user_uuid).await +} diff --git a/src-tauri/crates/business/src/services/proxies.rs b/src-tauri/crates/business/src/services/proxies.rs new file mode 100644 index 00000000..2507fea5 --- /dev/null +++ b/src-tauri/crates/business/src/services/proxies.rs @@ -0,0 +1,234 @@ +use uuid::Uuid; + +use crate::dto::ProxyDto; +use crate::entitys::{ + BatchImportProxiesRequest, CreateProxyRequest, ListProxiesRequest, UpdateProxyRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建代理 +pub async fn create_proxy_service( + svc_ctx: &SvcCtx, + owner_uuid: Uuid, + workspace_uuid: Uuid, + payload: &CreateProxyRequest, +) -> Result { + // 1. 检查工作空间代理配额是否充足 + let quota_available = models::check_quota(&svc_ctx.db, workspace_uuid, "proxies") + .await + .map_err(|e| e.to_string())?; + if !quota_available { + return Err("工作空间代理配额不足,无法创建新代理".to_string()); + } + + // 2. 创建代理 + let proxy_uuid = models::insert_proxy( + &svc_ctx.db, + workspace_uuid, + owner_uuid, + &payload.name, + &payload.host, + payload.port, + &payload.proxy_type, + payload.username.as_deref(), + payload.password.as_deref(), + payload.country.as_deref(), + payload.city.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + + // 3. 更新工作空间配额(创建后增加使用数) + models::increment_used_proxies(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(proxy_uuid) +} + +/// 获取代理列表(根据可见性过滤) +pub async fn get_proxies_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + current_team_uuid: Option, + payload: &ListProxiesRequest, +) -> Result<(Vec, i64), String> { + let filters = payload.filters.as_ref(); + let keyword = filters + .and_then(|f| f.keyword.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let proxy_type = filters + .and_then(|f| f.proxy_type.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let status = filters + .and_then(|f| f.status.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let country = filters + .and_then(|f| f.country.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + + let page = payload.pagination.page.max(1); + let page_size = payload.pagination.page_size.max(1); + let offset = (page - 1) * page_size; + + let proxies = models::fetch_visible_proxies_for_user_paginated( + &svc_ctx.db, + workspace_uuid, + user_uuid, + current_team_uuid, + keyword, + proxy_type, + status, + country, + offset, + page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_visible_proxies_for_user_count( + &svc_ctx.db, + workspace_uuid, + user_uuid, + current_team_uuid, + keyword, + proxy_type, + status, + country, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((proxies, total)) +} + +/// 获取代理详情 +pub async fn get_proxy_service(svc_ctx: &SvcCtx, proxy_uuid: Uuid) -> Result { + models::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string()) +} + +/// 更新代理 +pub async fn update_proxy_service( + svc_ctx: &SvcCtx, + payload: &UpdateProxyRequest, +) -> Result<(), String> { + models::update_proxy( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.host.as_deref(), + payload.port, + payload.proxy_type.as_deref(), + payload.username.as_deref(), + payload.password.as_deref(), + payload.country.as_deref(), + payload.city.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除代理 +pub async fn delete_proxy_service(svc_ctx: &SvcCtx, proxy_uuid: Uuid) -> Result<(), String> { + // 1. 获取代理信息(用于获取 workspace_uuid) + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let workspace_uuid = proxy.workspace_uuid; + + // 2. 删除代理 + models::delete_proxy(&svc_ctx.db, proxy_uuid).await.map_err(|e| e.to_string())?; + + // 3. 更新工作空间配额(删除后减少使用数) + models::decrement_used_proxies(&svc_ctx.db, workspace_uuid, 1) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 批量删除代理 +pub async fn batch_delete_proxies_service( + svc_ctx: &SvcCtx, + proxy_uuids: &[Uuid], +) -> Result { + models::batch_delete_proxies(&svc_ctx.db, proxy_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// 批量导入代理 +/// +/// 接收客户端已解析好的代理列表,直接保存到数据库 +pub async fn batch_import_proxies_service( + svc_ctx: &SvcCtx, + owner_uuid: Uuid, + workspace_uuid: Uuid, + payload: &BatchImportProxiesRequest, +) -> Result { + // 1. 检查工作空间代理配额是否充足(检查是否有足够配额导入所有代理) + let import_count = payload.proxies.len() as i32; + let quota = models::fetch_workspace_quota(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间配额不存在".to_string())?; + + if quota.used_proxies + import_count > quota.max_proxies { + return Err(format!( + "工作空间代理配额不足,当前已使用 {}/{},无法导入 {} 个代理", + quota.used_proxies, quota.max_proxies, import_count + )); + } + + let mut success_count = 0; + let mut failed_count = 0; + let mut errors: Vec = vec![]; + for (index, proxy) in payload.proxies.iter().enumerate() { + let result = models::insert_proxy( + &svc_ctx.db, + workspace_uuid, + owner_uuid, + &proxy.name, + &proxy.host, + proxy.port, + &proxy.proxy_type, + proxy.username.as_deref(), + proxy.password.as_deref(), + proxy.country.as_deref(), + proxy.city.as_deref(), + ) + .await; + + match result { + Ok(_) => { + success_count += 1; + // 更新配额(每成功导入一个代理就增加配额使用数) + if let Err(e) = models::increment_used_proxies(&svc_ctx.db, workspace_uuid, 1).await + { + errors.push(format!("第 {} 项导入成功但更新配额失败: {}", index + 1, e)); + } + } + Err(e) => { + failed_count += 1; + errors.push(format!("第 {} 项: {}", index + 1, e)); + } + } + } + + Ok(crate::entitys::BatchImportResponse { + success_count, + failed_count, + errors, + }) +} diff --git a/src-tauri/crates/business/src/services/proxy_visibility.rs b/src-tauri/crates/business/src/services/proxy_visibility.rs new file mode 100644 index 00000000..b918033a --- /dev/null +++ b/src-tauri/crates/business/src/services/proxy_visibility.rs @@ -0,0 +1,167 @@ +use uuid::Uuid; + +use crate::dto::ProxyDto; +use crate::entitys::{ + BatchSetProxyVisibleRequest, ListVisibleProxiesRequest, RemoveProxyVisibleRequest, + SetProxyVisibleRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 设置代理对团队可见 +pub async fn set_proxy_visible_to_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &SetProxyVisibleRequest, +) -> Result<(), String> { + // 检查权限:只有代理所有者或工作空间所有者可以设置可见性 + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let is_owner = proxy.owner_uuid == user_uuid; + let is_workspace_owner = + models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if !is_owner && !is_workspace_owner { + return Err("只有代理所有者或工作空间所有者可以设置可见性".to_string()); + } + + // 获取团队的工作空间 + let team = models::fetch_team_by_uuid(&svc_ctx.db, payload.team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + + if team.workspace_uuid != proxy.workspace_uuid { + return Err("团队和工作空间不匹配".to_string()); + } + + models::insert_proxy_visible_team( + &svc_ctx.db, + payload.proxy_uuid, + proxy.workspace_uuid, + payload.team_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 移除代理对团队的可见性 +pub async fn remove_proxy_visible_from_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &RemoveProxyVisibleRequest, +) -> Result<(), String> { + // 检查权限:只有代理所有者或工作空间所有者可以移除可见性 + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let is_owner = proxy.owner_uuid == user_uuid; + let is_workspace_owner = + models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if !is_owner && !is_workspace_owner { + return Err("只有代理所有者或工作空间所有者可以移除可见性".to_string()); + } + + models::remove_proxy_visible_team(&svc_ctx.db, payload.proxy_uuid, payload.team_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 批量设置代理可见性 +pub async fn batch_set_proxy_visible_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &BatchSetProxyVisibleRequest, +) -> Result<(), String> { + // 检查权限 + let proxy = models::fetch_proxy_by_uuid(&svc_ctx.db, payload.proxy_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "代理不存在".to_string())?; + + let is_owner = proxy.owner_uuid == user_uuid; + let is_workspace_owner = + models::check_workspace_owner(&svc_ctx.db, proxy.workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if !is_owner && !is_workspace_owner { + return Err("只有代理所有者或工作空间所有者可以设置可见性".to_string()); + } + + // 批量设置可见性 + for team_uuid in &payload.team_uuids { + models::insert_proxy_visible_team( + &svc_ctx.db, + payload.proxy_uuid, + proxy.workspace_uuid, + *team_uuid, + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +/// 获取可见的代理列表 +pub async fn get_visible_proxies_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &ListVisibleProxiesRequest, +) -> Result, String> { + models::fetch_visible_proxies_for_user( + &svc_ctx.db, + payload.workspace_uuid, + user_uuid, + payload.team_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 检查代理可见性 +pub async fn check_proxy_visibility_service( + svc_ctx: &SvcCtx, + proxy_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, +) -> Result { + models::check_proxy_visibility(&svc_ctx.db, proxy_uuid, workspace_uuid, team_uuid) + .await + .map_err(|e| e.to_string()) +} + +pub async fn get_proxy_visible_teams_service( + svc_ctx: &SvcCtx, + proxy_uuid: Uuid, +) -> Result, String> { + let visible_teams = models::fetch_visible_teams_by_proxy(&svc_ctx.db, proxy_uuid) + .await + .map_err(|error| error.to_string())?; + let mut details = Vec::with_capacity(visible_teams.len()); + for visible in visible_teams { + let team_name = models::fetch_team_by_uuid(&svc_ctx.db, visible.team_uuid) + .await + .map_err(|error| error.to_string())? + .map(|team| team.name); + details.push(crate::dto::ProxyVisibleTeamDetailDto { + proxy_uuid: visible.proxy_uuid, + workspace_uuid: visible.workspace_uuid, + team_uuid: visible.team_uuid, + team_name, + created_at: visible.created_at, + }); + } + Ok(details) +} diff --git a/src-tauri/crates/business/src/services/rpa.rs b/src-tauri/crates/business/src/services/rpa.rs new file mode 100644 index 00000000..becfc22f --- /dev/null +++ b/src-tauri/crates/business/src/services/rpa.rs @@ -0,0 +1,347 @@ +use uuid::Uuid; + +use crate::dto::{RpaTaskDto, RpaTaskStepDto}; +use crate::entitys::{ + CreateRpaTaskRequest, DuplicateRpaTaskRequest, ListRpaTasksRequest, UpdateRpaTaskRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// List RPA tasks. +pub async fn get_rpa_tasks_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &ListRpaTasksRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + let trigger_type = payload.filters.as_ref().and_then(|f| f.trigger_type.as_deref()); + + let tasks = models::rpa::fetch_rpa_tasks( + &svc_ctx.db, + team_uuid, + user_uuid, + keyword, + status, + trigger_type, + offset, + payload.pagination.page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::rpa::fetch_rpa_tasks_count( + &svc_ctx.db, + team_uuid, + user_uuid, + keyword, + status, + trigger_type, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((tasks, total)) +} + +/// Get RPA task detail. +pub async fn get_rpa_task_service( + svc_ctx: &SvcCtx, + task_uuid: Uuid, +) -> Result<(RpaTaskDto, Vec, Vec), String> { + let task = models::rpa::fetch_rpa_task_by_uuid(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "RPA task not found".to_string())?; + + let steps = models::rpa::fetch_rpa_task_steps(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string())?; + + let environments = models::rpa::fetch_rpa_task_environments(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string())?; + + let environment_uuids: Vec = + environments.into_iter().map(|e| e.environment_uuid).collect(); + + Ok((task, steps, environment_uuids)) +} + +/// Create an RPA task. +pub async fn create_rpa_task_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateRpaTaskRequest, +) -> Result { + let tags_json = payload.tags.as_ref().map(|t| serde_json::json!(t)); + + let task_uuid = models::rpa::insert_rpa_task( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + tags_json.as_ref(), + &payload.trigger_type, + payload.schedule.as_deref(), + payload.cron_expression.as_deref(), + &payload.run_mode, + payload.retry_count, + payload.retry_interval, + payload.timeout, + payload.concurrency, + payload.stop_on_error, + payload.notify_on_complete, + payload.notify_on_error, + ) + .await + .map_err(|e| e.to_string())?; + + // Persist ordered steps. + if let Some(steps) = &payload.steps { + for (i, step) in steps.iter().enumerate() { + models::rpa::insert_rpa_task_step( + &svc_ctx.db, + task_uuid, + &step.step_type, + &step.name, + &step.config, + step.enabled, + step.position_x, + step.position_y, + Some(step.sort_order.unwrap_or(i as i32)), + step.next_step_uuid, + step.branch_config.as_ref(), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + // Persist environment bindings. + if let Some(env_uuids) = &payload.environment_uuids { + for (i, env_uuid) in env_uuids.iter().enumerate() { + models::rpa::insert_rpa_task_environment( + &svc_ctx.db, + task_uuid, + *env_uuid, + Some(i as i32), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + Ok(task_uuid) +} + +/// Update an RPA task. +pub async fn update_rpa_task_service( + svc_ctx: &SvcCtx, + payload: &UpdateRpaTaskRequest, +) -> Result<(), String> { + let tags_json = payload.tags.as_ref().map(|t| serde_json::json!(t)); + + models::rpa::update_rpa_task( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + tags_json.as_ref(), + payload.trigger_type.as_deref(), + payload.schedule.as_deref(), + payload.cron_expression.as_deref(), + payload.run_mode.as_deref(), + payload.retry_count, + payload.retry_interval, + payload.timeout, + payload.concurrency, + payload.stop_on_error, + payload.notify_on_complete, + payload.notify_on_error, + ) + .await + .map_err(|e| e.to_string())?; + + // Replace stored steps when the caller sends a full step list. + if let Some(steps) = &payload.steps { + // Remove previous step rows first. + models::rpa::delete_rpa_task_steps(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())?; + + // Insert the new step rows. + for (i, step) in steps.iter().enumerate() { + models::rpa::insert_rpa_task_step( + &svc_ctx.db, + payload.uuid, + &step.step_type, + &step.name, + &step.config, + step.enabled, + step.position_x, + step.position_y, + Some(step.sort_order.unwrap_or(i as i32)), + step.next_step_uuid, + step.branch_config.as_ref(), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + // Replace environment bindings when provided. + if let Some(env_uuids) = &payload.environment_uuids { + // Remove previous environment bindings. + models::rpa::delete_rpa_task_environments(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())?; + + // Insert the new environment bindings. + for (i, env_uuid) in env_uuids.iter().enumerate() { + models::rpa::insert_rpa_task_environment( + &svc_ctx.db, + payload.uuid, + *env_uuid, + Some(i as i32), + ) + .await + .map_err(|e| e.to_string())?; + } + } + + Ok(()) +} + +/// Soft-delete an RPA task. +pub async fn delete_rpa_task_service(svc_ctx: &SvcCtx, task_uuid: Uuid) -> Result<(), String> { + models::rpa::delete_rpa_task(&svc_ctx.db, task_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// Soft-delete RPA tasks in batch. +pub async fn batch_delete_rpa_tasks_service( + svc_ctx: &SvcCtx, + task_uuids: &[Uuid], +) -> Result { + models::rpa::batch_delete_rpa_tasks(&svc_ctx.db, task_uuids) + .await + .map_err(|e| e.to_string()) +} + +/// Duplicate an RPA task. +pub async fn duplicate_rpa_task_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &DuplicateRpaTaskRequest, +) -> Result { + // Load source task data. + let (task, steps, environment_uuids) = get_rpa_task_service(svc_ctx, payload.uuid).await?; + + let new_name = payload.new_name.clone().unwrap_or_else(|| format!("{} (copy)", task.name)); + + // Create duplicated task row. + let new_task_uuid = models::rpa::insert_rpa_task( + &svc_ctx.db, + user_uuid, + team_uuid, + &new_name, + task.description.as_deref(), + task.tags.as_ref(), + &task.trigger_type, + task.schedule.as_deref(), + task.cron_expression.as_deref(), + &task.run_mode, + task.retry_count, + task.retry_interval, + task.timeout, + task.concurrency, + task.stop_on_error, + task.notify_on_complete, + task.notify_on_error, + ) + .await + .map_err(|e| e.to_string())?; + + // Copy steps. + for step in steps { + models::rpa::insert_rpa_task_step( + &svc_ctx.db, + new_task_uuid, + &step.step_type, + &step.name, + &step.config, + step.enabled, + step.position_x, + step.position_y, + step.sort_order, + step.next_step_uuid, + step.branch_config.as_ref(), + ) + .await + .map_err(|e| e.to_string())?; + } + + // Copy environment bindings. + for (i, env_uuid) in environment_uuids.iter().enumerate() { + models::rpa::insert_rpa_task_environment( + &svc_ctx.db, + new_task_uuid, + *env_uuid, + Some(i as i32), + ) + .await + .map_err(|e| e.to_string())?; + } + + Ok(new_task_uuid) +} + +/// Export an RPA task. +pub async fn export_rpa_task_service( + svc_ctx: &SvcCtx, + task_uuid: Uuid, +) -> Result<(String, String), String> { + let (task, steps, environment_uuids) = get_rpa_task_service(svc_ctx, task_uuid).await?; + + let export_data = serde_json::json!({ + "name": task.name, + "description": task.description, + "tags": task.tags, + "trigger_type": task.trigger_type, + "schedule": task.schedule, + "cron_expression": task.cron_expression, + "run_mode": task.run_mode, + "retry_count": task.retry_count, + "retry_interval": task.retry_interval, + "timeout": task.timeout, + "concurrency": task.concurrency, + "stop_on_error": task.stop_on_error, + "notify_on_complete": task.notify_on_complete, + "notify_on_error": task.notify_on_error, + "steps": steps.iter().map(|s| serde_json::json!({ + "step_type": s.step_type, + "name": s.name, + "config": s.config, + "enabled": s.enabled, + "position_x": s.position_x, + "position_y": s.position_y, + "sort_order": s.sort_order, + "next_step_uuid": s.next_step_uuid, + "branch_config": s.branch_config, + })).collect::>(), + "environment_uuids": environment_uuids, + }); + + let content = serde_json::to_string_pretty(&export_data).map_err(|e| e.to_string())?; + let filename = format!("rpa_task_{}.json", task_uuid); + + Ok((content, filename)) +} diff --git a/src-tauri/crates/business/src/services/tags.rs b/src-tauri/crates/business/src/services/tags.rs new file mode 100644 index 00000000..0f782a8b --- /dev/null +++ b/src-tauri/crates/business/src/services/tags.rs @@ -0,0 +1,64 @@ +use uuid::Uuid; + +use crate::dto::TagDto; +use crate::entitys::tags::{CreateTagRequest, UpdateTagRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建标签 +pub async fn create_tag_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateTagRequest, +) -> Result { + models::insert_tag( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.name, + payload.color.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取标签列表 +pub async fn get_tags_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, +) -> Result, String> { + models::fetch_tags(&svc_ctx.db, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取标签详情 +pub async fn get_tag_service(svc_ctx: &SvcCtx, tag_uuid: Uuid) -> Result { + models::fetch_tag_by_uuid(&svc_ctx.db, tag_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "标签不存在".to_string()) +} + +/// 更新标签 +pub async fn update_tag_service( + svc_ctx: &SvcCtx, + payload: &UpdateTagRequest, +) -> Result<(), String> { + models::update_tag( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.color.as_deref(), + payload.sort_order, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除标签 +pub async fn delete_tag_service(svc_ctx: &SvcCtx, tag_uuid: Uuid) -> Result<(), String> { + models::delete_tag(&svc_ctx.db, tag_uuid).await.map_err(|e| e.to_string()) +} diff --git a/src-tauri/crates/business/src/services/teams.rs b/src-tauri/crates/business/src/services/teams.rs new file mode 100644 index 00000000..f42b5667 --- /dev/null +++ b/src-tauri/crates/business/src/services/teams.rs @@ -0,0 +1,342 @@ +use uuid::Uuid; + +use crate::dto::{TeamDto, TeamMemberDto}; +use crate::entitys::{ + AddMemberRequest, CreateTeamRequest, ListTeamMembersRequest, SwitchTeamRequest, + UpdateMemberRoleRequest, UpdateTeamRequest, +}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建团队 +pub async fn create_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &CreateTeamRequest, +) -> Result { + models::insert_team(&svc_ctx.db, user_uuid, payload) + .await + .map_err(|e| e.to_string()) +} + +/// 获取团队详情 +pub async fn get_team_service(svc_ctx: &SvcCtx, team_uuid: Uuid) -> Result { + models::fetch_team_by_uuid(&svc_ctx.db, team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string()) +} + +/// 获取用户在本机加入的所有团队。 +pub async fn get_user_teams_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::fetch_all_user_teams(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 获取用户当前团队 +pub async fn get_current_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::fetch_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 切换团队 +pub async fn switch_team_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &SwitchTeamRequest, +) -> Result<(), String> { + let team = models::fetch_team_by_uuid(&svc_ctx.db, payload.team_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "团队不存在".to_string())?; + models::fetch_team_member( + &svc_ctx.db, + team.workspace_uuid, + payload.team_uuid, + user_uuid, + ) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + models::user::set_user_current_workspace_and_team( + &svc_ctx.db, + user_uuid, + team.workspace_uuid, + payload.team_uuid, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 更新团队信息 +pub async fn update_team_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, + payload: &UpdateTeamRequest, +) -> Result<(), String> { + // 检查权限(仅所有者和管理员可以更新) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, payload.uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if member.role != "owner" && member.role != "admin" { + return Err("权限不足".to_string()); + } + + models::update_team( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.avatar_hash.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取团队成员列表 +pub async fn get_team_members_service( + svc_ctx: &SvcCtx, + team_uuid: Uuid, + payload: &ListTeamMembersRequest, +) -> Result<(Vec, i64), String> { + let offset = (payload.pagination.page - 1) * payload.pagination.page_size; + + // 提取筛选条件 + let keyword = payload.filters.as_ref().and_then(|f| f.keyword.as_deref()); + let role = payload.filters.as_ref().and_then(|f| f.role.as_deref()); + let status = payload.filters.as_ref().and_then(|f| f.status.as_deref()); + + let members = models::fetch_team_members( + &svc_ctx.db, + team_uuid, + offset, + payload.pagination.page_size, + keyword, + role, + status, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_team_member_count(&svc_ctx.db, team_uuid, keyword, role, status) + .await + .map_err(|e| e.to_string())?; + + Ok((members, total)) +} + +/// 将另一个本地用户直接加入团队。 +pub async fn add_member_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + added_by_uuid: Uuid, + payload: &AddMemberRequest, +) -> Result { + // 检查操作者权限(工作空间级别) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, added_by_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if member.role != "owner" && member.role != "admin" { + return Err("权限不足".to_string()); + } + + if !matches!(payload.role.as_str(), "admin" | "editor" | "viewer") { + return Err("无效的团队角色".to_string()); + } + let user_info = crate::models::user::fetch_user_info_by_uuid(&svc_ctx.db, payload.user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "本地用户不存在".to_string())?; + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM local_user_auth WHERE user_uuid = $1") + .bind(payload.user_uuid) + .fetch_one(&svc_ctx.db) + .await + .map_err(|e| e.to_string())? + .gt(&0) + .then_some(()) + .ok_or_else(|| "目标不是本地用户".to_string())?; + + // 检查用户是否已是团队成员 + if models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_info.user_uuid) + .await + .map_err(|e| e.to_string())? + .is_some() + { + return Err("该用户已是团队成员".to_string()); + } + + models::insert_team_member( + &svc_ctx.db, + team_uuid, + user_info.user_uuid, + &payload.role, + Some(added_by_uuid), + ) + .await + .map_err(|e| e.to_string())?; + models::update_used_team_members(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| format!("更新配额失败: {e}"))?; + + Ok(user_info.user_uuid) +} + +/// 更新成员角色 +pub async fn update_member_role_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + operator_uuid: Uuid, + payload: &UpdateMemberRoleRequest, +) -> Result { + // 检查操作者权限(工作空间级别) + let operator = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, operator_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if operator.role != "owner" && operator.role != "admin" { + return Err("权限不足".to_string()); + } + + // 不能修改所有者角色 + let target = + models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, payload.member_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "成员不存在".to_string())?; + + if target.role == "owner" { + return Err("不能修改所有者角色".to_string()); + } + + // 管理员不能设置其他管理员 + if operator.role == "admin" && payload.role == "admin" { + return Err("管理员不能设置其他管理员".to_string()); + } + + models::update_member_role(&svc_ctx.db, team_uuid, payload.member_uuid, &payload.role) + .await + .map_err(|e| e.to_string())?; + + // 返回更新后的成员信息 + models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, payload.member_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "获取更新后的成员信息失败".to_string()) +} + +/// 移除成员 +pub async fn remove_member_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + operator_uuid: Uuid, + member_uuid: Uuid, +) -> Result<(), String> { + // 检查操作者权限(工作空间级别) + let operator = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, operator_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + if operator.role != "owner" && operator.role != "admin" { + return Err("权限不足".to_string()); + } + + // 不能移除所有者 + let target = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, member_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "成员不存在".to_string())?; + + if target.role == "owner" { + return Err("不能移除所有者".to_string()); + } + + // 管理员不能移除其他管理员 + if operator.role == "admin" && target.role == "admin" { + return Err("管理员不能移除其他管理员".to_string()); + } + + models::remove_team_member(&svc_ctx.db, team_uuid, member_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新成员配额(重新计算所有团队的活跃成员总数) + models::update_used_team_members(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + Ok(()) +} + +/// 退出团队 +pub async fn leave_team_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), String> { + // 检查用户是否是团队成员(工作空间级别) + let member = models::fetch_team_member(&svc_ctx.db, workspace_uuid, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "您不是该团队的成员".to_string())?; + + // 所有者不能退出团队 + if member.role == "owner" { + return Err("团队所有者不能退出团队,请先转移所有权或解散团队".to_string()); + } + + // 移除成员 + models::remove_team_member(&svc_ctx.db, team_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + // 更新成员配额(重新计算所有团队的活跃成员总数) + models::update_used_team_members(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| format!("更新配额失败: {}", e))?; + + // 如果当前团队是用户的活跃团队,则需要切换到其他团队 + let current_team = models::fetch_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if current_team == Some(team_uuid) { + // 获取用户的其他团队(工作空间级别) + let teams = models::fetch_user_teams(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if let Some(first_team) = teams.first() { + // 切换到第一个可用团队 + models::set_user_current_team(&svc_ctx.db, user_uuid, first_team.uuid) + .await + .map_err(|e| e.to_string())?; + } else { + // 没有其他团队,清除当前团队 + models::clear_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + } + } + + Ok(()) +} diff --git a/src-tauri/crates/business/src/services/templates.rs b/src-tauri/crates/business/src/services/templates.rs new file mode 100644 index 00000000..27f9d12b --- /dev/null +++ b/src-tauri/crates/business/src/services/templates.rs @@ -0,0 +1,381 @@ +use uuid::Uuid; + +use crate::dto::TemplateDto; +use crate::entitys::{ + ApplyTemplateRequest, AssociationsStatus, CreateFromTemplateRequest, CreateTemplateRequest, + TemplateDetailResponse, UpdateTemplateRequest, +}; +use crate::models; +use crate::services; +use crate::svc_ctx::SvcCtx; + +/// 创建模板 +pub async fn create_template_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + payload: &CreateTemplateRequest, +) -> Result { + // 确定要存储的完整数据 + let environment_data_json: serde_json::Value = if let Some(env_uuid) = payload.environment_uuid + { + // 如果提供了环境 UUID,先查询环境获取 workspace_uuid 和 team_uuid + let env = models::fetch_environment_by_uuid_unfiltered(&svc_ctx.db, env_uuid) + .await + .map_err(|e| format!("查询环境失败: {}", e))? + .ok_or_else(|| "环境不存在".to_string())?; + + // 获取完整的环境详情(带权限检查) + let env_detail = services::environments::get_environment_detail_service( + svc_ctx, + env.workspace_uuid, + env.team_uuid, + user_uuid, + env_uuid, + ) + .await + .map_err(|e| format!("获取环境详情失败: {}", e))?; + serde_json::to_value(&env_detail).map_err(|e| format!("序列化环境详情失败: {}", e))? + } else if let Some(ref env_data) = payload.environment_data { + // 如果直接提供了环境详情数据,使用它 + env_data.clone() + } else { + return Err("必须提供 environment_uuid 或 environment_data 之一".to_string()); + }; + + // 从环境数据中提取摘要信息(从标准结构 config.window_info 中提取) + let system_info = environment_data_json + .get("config") + .and_then(|v| v.get("window_info")) + .and_then(|v| v.get("system")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let kernel_info = environment_data_json + .get("config") + .and_then(|v| v.get("window_info")) + .and_then(|v| v.get("kernel")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + models::insert_template( + &svc_ctx.db, + user_uuid, + team_uuid, + &payload.name, + payload.description.as_deref(), + payload.is_public.unwrap_or(false), + system_info.as_deref(), + kernel_info.as_deref(), + &environment_data_json, + ) + .await + .map_err(|e| e.to_string()) +} + +/// 获取模板列表 +pub async fn get_templates_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + team_uuid: Option, + is_public: Option, + page: i64, + page_size: i64, +) -> Result<(Vec, i64), String> { + let offset = (page - 1) * page_size; + + let templates = models::fetch_templates( + &svc_ctx.db, + team_uuid, + user_uuid, + is_public, + offset, + page_size, + ) + .await + .map_err(|e| e.to_string())?; + + let total = models::fetch_templates_count(&svc_ctx.db, team_uuid, user_uuid, is_public) + .await + .map_err(|e| e.to_string())?; + + Ok((templates, total)) +} + +/// 获取模板详情 +pub async fn get_template_service( + svc_ctx: &SvcCtx, + template_uuid: Uuid, + for_create: bool, +) -> Result { + let template = models::fetch_template_by_uuid(&svc_ctx.db, template_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "模板不存在".to_string())?; + + // 如果 for_create 为 true,检查关联数据是否存在 + let associations_status = if for_create { + Some(check_template_associations(svc_ctx, &template).await?) + } else { + None + }; + + Ok(TemplateDetailResponse { + template, + associations_status, + }) +} + +/// 检查模板关联数据是否存在 +async fn check_template_associations( + svc_ctx: &SvcCtx, + template: &TemplateDto, +) -> Result { + // 解析模板中的环境详情数据 + let env_detail: crate::entitys::EnvironmentDetailResponse = + serde_json::from_value(template.config_json.clone()) + .map_err(|e| format!("解析模板数据失败: {}", e))?; + + // 检查分组是否存在 + let group_exists = if let Some(group_uuid) = env_detail.environment.group_uuid { + models::fetch_group_by_uuid(&svc_ctx.db, group_uuid) + .await + .map_err(|e| e.to_string())? + .is_some() + } else { + false + }; + + // 检查标签是否存在 + let mut tags_exist = std::collections::HashMap::new(); + for tag in &env_detail.tags { + let exists = models::fetch_tag_by_uuid(&svc_ctx.db, tag.uuid) + .await + .map_err(|e| e.to_string())? + .is_some(); + tags_exist.insert(tag.uuid, exists); + } + + // 检查账号是否存在 + let mut accounts_exist = std::collections::HashMap::new(); + for account in &env_detail.accounts { + let exists = models::fetch_platform_account_by_uuid(&svc_ctx.db, account.uuid) + .await + .map_err(|e| e.to_string())? + .is_some(); + accounts_exist.insert(account.uuid, exists); + } + + // 检查代理是否存在 + let proxy_exists = if let Some(proxy_uuid) = env_detail.environment.proxy_uuid { + models::fetch_proxy_by_uuid(&svc_ctx.db, proxy_uuid) + .await + .map_err(|e| e.to_string())? + .is_some() + } else { + false + }; + + Ok(AssociationsStatus { + group_exists, + tags_exist, + accounts_exist, + proxy_exists, + }) +} + +/// 更新模板 +pub async fn update_template_service( + svc_ctx: &SvcCtx, + payload: &UpdateTemplateRequest, +) -> Result<(), String> { + models::update_template( + &svc_ctx.db, + payload.uuid, + payload.name.as_deref(), + payload.description.as_deref(), + payload.is_public, + payload.config_json.as_ref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 删除模板 +pub async fn delete_template_service(svc_ctx: &SvcCtx, template_uuid: Uuid) -> Result<(), String> { + models::delete_template(&svc_ctx.db, template_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 应用模板到现有环境(更新环境配置) +pub async fn apply_template_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + team_uuid: Uuid, + user_uuid: Uuid, + payload: &ApplyTemplateRequest, +) -> Result<(), String> { + // 获取模板配置(不需要检查关联数据) + let template_response = get_template_service(svc_ctx, payload.template_uuid, false).await?; + let template = &template_response.template; + + // 解析模板中的环境详情数据 + // 先转换为字符串再反序列化,确保类型正确 + let env_detail: crate::entitys::EnvironmentDetailResponse = serde_json::from_str( + &serde_json::to_string(&template.config_json) + .map_err(|e| format!("序列化模板数据失败: {}", e))?, + ) + .map_err(|e| format!("解析模板数据失败: {}", e))?; + + // 更新环境配置 + let update_req = crate::entitys::UpdateEnvironmentRequest { + uuid: payload.environment_uuid, + name: None, + description: None, + group_uuid: None, + cookies: Some( + env_detail + .cookies + .iter() + .map(|item| crate::entitys::CookieGroupInput { + site: item.site.clone(), + cookie_text: item.cookie_text.clone(), + }) + .collect(), + ), + urls: Some( + env_detail + .urls + .iter() + .map(|item| crate::entitys::UrlInput { + url: item.url.clone(), + title: item.title.clone(), + sort_order: item.sort_order, + }) + .collect(), + ), + config: env_detail.config.map(|config| crate::entitys::EnvironmentConfigRequest { + window_info: config.window_info, + basic_settings: config.basic_settings, + fingerprint_settings: config.fingerprint_settings, + device_settings: config.device_settings, + preference_settings: config.preference_settings, + project_metadata: config.project_metadata, + }), + }; + + services::environments::update_environment_service( + svc_ctx, + workspace_uuid, + team_uuid, + user_uuid, + &update_req, + ) + .await + .map_err(|e| e.to_string())?; + + // 增加模板使用次数 + let _ = models::increment_template_usage(&svc_ctx.db, payload.template_uuid).await; + + Ok(()) +} + +/// 从模板创建环境 +pub async fn create_from_template_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, + team_uuid: Uuid, + payload: &CreateFromTemplateRequest, +) -> Result { + // 获取模板(不需要检查关联数据,因为这是直接创建,不经过前端表单) + let template_response = get_template_service(svc_ctx, payload.template_uuid, false).await?; + let template = &template_response.template; + + // 解析模板中的环境详情数据 + // 先转换为字符串再反序列化,确保类型正确 + let env_detail: crate::entitys::EnvironmentDetailResponse = serde_json::from_str( + &serde_json::to_string(&template.config_json) + .map_err(|e| format!("序列化模板数据失败: {}", e))?, + ) + .map_err(|e| format!("解析模板数据失败: {}", e))?; + + // 使用提供的参数覆盖模板中的值 + let env_name = payload.name.as_deref().unwrap_or(&env_detail.environment.name); + let env_description = + payload.description.as_ref().or(env_detail.environment.description.as_ref()); + let group_uuid = payload.group_uuid.or(env_detail.environment.group_uuid); + let proxy_uuid = env_detail.environment.proxy_uuid; + + // 提取标签 UUIDs + let tag_uuids: Vec = env_detail.tags.iter().map(|tag| tag.uuid).collect(); + + // 提取账号 UUIDs + let account_uuids: Vec = env_detail.accounts.iter().map(|acc| acc.uuid).collect(); + + // 构建创建环境请求 + let config = env_detail.config.ok_or_else(|| "模板中缺少配置信息".to_string())?; + + let create_req = crate::entitys::CreateEnvironmentRequest { + name: env_name.to_string(), + description: env_description.cloned(), + group_uuid, + tag_uuids: if tag_uuids.is_empty() { + None + } else { + Some(tag_uuids) + }, + account_uuids: if account_uuids.is_empty() { + None + } else { + Some(account_uuids) + }, + proxy_uuid, + cookies: Some( + env_detail + .cookies + .iter() + .map(|item| crate::entitys::CookieGroupInput { + site: item.site.clone(), + cookie_text: item.cookie_text.clone(), + }) + .collect(), + ), + urls: Some( + env_detail + .urls + .iter() + .map(|item| crate::entitys::UrlInput { + url: item.url.clone(), + title: item.title.clone(), + sort_order: item.sort_order, + }) + .collect(), + ), + config: crate::entitys::EnvironmentConfigRequest { + window_info: config.window_info, + basic_settings: config.basic_settings, + fingerprint_settings: config.fingerprint_settings, + device_settings: config.device_settings, + preference_settings: config.preference_settings, + project_metadata: config.project_metadata, + }, + }; + + // 创建环境 + let env_uuid = services::environments::create_environment_service( + svc_ctx, + user_uuid, + workspace_uuid, + team_uuid, + &create_req, + ) + .await + .map_err(|e| e.to_string())?; + + // 增加模板使用次数 + let _ = models::increment_template_usage(&svc_ctx.db, payload.template_uuid).await; + + Ok(env_uuid) +} diff --git a/src-tauri/crates/business/src/services/time.rs b/src-tauri/crates/business/src/services/time.rs new file mode 100644 index 00000000..2782dde4 --- /dev/null +++ b/src-tauri/crates/business/src/services/time.rs @@ -0,0 +1,4 @@ +pub fn now_service() -> String { + let now = chrono::Utc::now().to_string(); + now +} diff --git a/src-tauri/crates/business/src/services/workspace_quotas.rs b/src-tauri/crates/business/src/services/workspace_quotas.rs new file mode 100644 index 00000000..38692bd0 --- /dev/null +++ b/src-tauri/crates/business/src/services/workspace_quotas.rs @@ -0,0 +1,71 @@ +use uuid::Uuid; + +use crate::dto::WorkspaceQuotaDto; +use crate::entitys::{GetWorkspaceQuotaRequest, UpdateQuotaUsageRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 获取工作空间配额 +pub async fn get_workspace_quota_service( + svc_ctx: &SvcCtx, + payload: &GetWorkspaceQuotaRequest, +) -> Result { + let workspace_uuid = + payload.workspace_uuid.ok_or_else(|| "工作空间 UUID 不能为空".to_string())?; + + models::fetch_workspace_quota(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "配额不存在".to_string()) +} + +/// 检查配额是否充足 +pub async fn check_quota_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + quota_type: &str, +) -> Result { + models::check_quota(&svc_ctx.db, workspace_uuid, quota_type) + .await + .map_err(|e| e.to_string()) +} + +/// 更新配额使用情况 +pub async fn update_quota_usage_service( + svc_ctx: &SvcCtx, + payload: &UpdateQuotaUsageRequest, +) -> Result<(), String> { + match payload.quota_type.as_str() { + "environments" => { + if payload.increment { + models::increment_used_environments( + &svc_ctx.db, + payload.workspace_uuid, + payload.amount, + ) + .await + } else { + models::decrement_used_environments( + &svc_ctx.db, + payload.workspace_uuid, + payload.amount, + ) + .await + } + } + "proxies" => { + if payload.increment { + models::increment_used_proxies(&svc_ctx.db, payload.workspace_uuid, payload.amount) + .await + } else { + models::decrement_used_proxies(&svc_ctx.db, payload.workspace_uuid, payload.amount) + .await + } + } + "team_members" => { + models::update_used_team_members(&svc_ctx.db, payload.workspace_uuid).await + } + _ => return Err("不支持的配额类型".to_string()), + } + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/crates/business/src/services/workspaces.rs b/src-tauri/crates/business/src/services/workspaces.rs new file mode 100644 index 00000000..a350152a --- /dev/null +++ b/src-tauri/crates/business/src/services/workspaces.rs @@ -0,0 +1,187 @@ +use uuid::Uuid; + +use crate::dto::WorkspaceDto; +use crate::entitys::{CreateTeamRequest, CreateWorkspaceRequest, UpdateWorkspaceRequest}; +use crate::models; +use crate::svc_ctx::SvcCtx; + +/// 创建工作空间 +pub async fn create_workspace_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &CreateWorkspaceRequest, +) -> Result { + // 获取用户信息,用于生成团队名称 + let user_info = models::user::fetch_user_info_by_uuid(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "用户不存在".to_string())?; + + // 生成团队名称 + let team_name = + user_info.nickname.as_ref().map(|n| format!("{} 的团队", n)).unwrap_or_else(|| { + format!( + "{} 的团队", + user_info.email.split('@').next().unwrap_or("用户") + ) + }); + + // 创建工作空间 + let workspace_uuid = models::insert_workspace(&svc_ctx.db, user_uuid, payload) + .await + .map_err(|e| e.to_string())?; + + // 创建默认配额(从配置读取) + let quota = &svc_ctx.workspace_quota.default; + models::insert_or_update_workspace_quota( + &svc_ctx.db, + workspace_uuid, + quota.max_environments, + quota.max_team_members, + quota.max_proxies, + quota.max_rpa_tasks, + ) + .await + .map_err(|e| e.to_string())?; + + // 创建默认团队(每个工作空间自动创建一个团队) + let team_request = CreateTeamRequest { + workspace_uuid, + name: team_name, + description: Some("默认团队".to_string()), + }; + let team_uuid = models::insert_team(&svc_ctx.db, user_uuid, &team_request) + .await + .map_err(|e| e.to_string())?; + + // 设置用户当前工作空间和团队,确保上下文始终一致。 + models::user::set_user_current_workspace_and_team( + &svc_ctx.db, + user_uuid, + workspace_uuid, + team_uuid, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(workspace_uuid) +} + +/// 获取工作空间详情 +pub async fn get_workspace_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, +) -> Result { + models::fetch_workspace_by_uuid(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间不存在".to_string()) +} + +/// 获取用户所属的所有工作空间 +pub async fn get_user_workspaces_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, +) -> Result, String> { + models::fetch_user_workspaces(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 更新工作空间 +pub async fn update_workspace_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + payload: &UpdateWorkspaceRequest, +) -> Result<(), String> { + // 检查权限(只有所有者可以更新) + let workspace = models::fetch_workspace_by_uuid(&svc_ctx.db, payload.uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间不存在".to_string())?; + + if workspace.owner_uuid != user_uuid { + return Err("只有工作空间所有者可以更新".to_string()); + } + + models::update_workspace(&svc_ctx.db, payload.uuid, payload.name.as_deref()) + .await + .map_err(|e| e.to_string()) +} + +/// 删除工作空间(软删除) +pub async fn delete_workspace_service( + svc_ctx: &SvcCtx, + user_uuid: Uuid, + workspace_uuid: Uuid, +) -> Result<(), String> { + // 检查权限(只有所有者可以删除) + let workspace = models::fetch_workspace_by_uuid(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作空间不存在".to_string())?; + + if workspace.owner_uuid != user_uuid { + return Err("只有工作空间所有者可以删除".to_string()); + } + + let current_workspace_uuid = models::user::fetch_user_current_workspace(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if current_workspace_uuid == Some(workspace_uuid) { + return Err("不能删除当前正在使用的工作空间,请先切换到其他工作空间".to_string()); + } + + // 检查用户是否只有一个工作空间,如果是则不允许删除 + let user_workspaces = models::fetch_user_workspaces(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + if user_workspaces.len() <= 1 { + return Err("至少需要保留一个工作空间,无法删除最后一个工作空间".to_string()); + } + + models::delete_workspace(&svc_ctx.db, workspace_uuid) + .await + .map_err(|e| e.to_string()) +} + +/// 检查用户是否是工作空间所有者 +pub async fn check_workspace_owner_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result { + models::check_workspace_owner(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string()) +} + +pub async fn switch_workspace_service( + svc_ctx: &SvcCtx, + workspace_uuid: Uuid, + user_uuid: Uuid, +) -> Result<(), String> { + let teams = models::fetch_user_teams(&svc_ctx.db, workspace_uuid, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let current_team_uuid = models::fetch_user_current_team(&svc_ctx.db, user_uuid) + .await + .map_err(|e| e.to_string())?; + + let team_uuid = current_team_uuid + .filter(|current| teams.iter().any(|team| team.uuid == *current)) + .or_else(|| teams.first().map(|team| team.uuid)) + .ok_or_else(|| "该工作空间下没有可用团队".to_string())?; + + models::user::set_user_current_workspace_and_team( + &svc_ctx.db, + user_uuid, + workspace_uuid, + team_uuid, + ) + .await + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/crates/business/src/state.rs b/src-tauri/crates/business/src/state.rs new file mode 100644 index 00000000..e4a9b973 --- /dev/null +++ b/src-tauri/crates/business/src/state.rs @@ -0,0 +1,82 @@ +use uuid::Uuid; + +/// 当前用户 +#[derive(Debug, Clone)] +pub struct CurrentUser { + pub user_uuid: Uuid, +} + +/// 当前工作空间 +#[derive(Debug, Clone)] +pub struct CurrentWorkspace { + pub workspace_uuid: Uuid, +} + +/// 当前 IP 地址 +#[derive(Debug, Clone)] +pub struct CurrentIpAddr { + pub real_ip: String, +} + +/// 请求上下文 - 包含所有请求相关的上下文信息 +/// +/// 在中间件中逐步填充,handler 中通过 Extension 获取 +#[derive(Debug, Clone, Default)] +pub struct RequestContext { + /// 当前用户信息 + pub current_user: Option, + /// 当前 IP 地址 + pub current_ip_addr: Option, + /// 当前团队 UUID + pub current_team_uuid: Option, + /// 当前工作空间 UUID + pub current_workspace_uuid: Option, + /// 资源标识符:method+path(去除 /api/v*/ 前缀,保留前导斜杠) + /// 例如:POST+/environments, GET+/proxies + pub resource_identifier: Option, +} + +impl RequestContext { + /// 获取用户 UUID(如果已认证) + pub fn user_uuid(&self) -> Option { + self.current_user.as_ref().map(|u| u.user_uuid) + } + + /// 获取用户 UUID,如果未认证则 panic + pub fn user_uuid_unwrap(&self) -> Uuid { + self.current_user.as_ref().expect("用户未认证").user_uuid + } + + /// 获取 IP 地址 + pub fn ip(&self) -> Option<&str> { + self.current_ip_addr.as_ref().map(|i| i.real_ip.as_str()) + } + + /// 获取 IP 地址,如果不存在则返回 "unknown" + pub fn ip_or_unknown(&self) -> &str { + self.ip().unwrap_or("unknown") + } + + /// 获取工作空间 UUID + pub fn workspace_uuid(&self) -> Option { + self.current_workspace_uuid + } + + /// 获取工作空间 UUID,如果不存在则 panic + pub fn workspace_uuid_unwrap(&self) -> Uuid { + self.current_workspace_uuid.expect("工作空间未设置") + } + + /// 获取资源标识符 + pub fn resource_identifier(&self) -> Option<&str> { + self.resource_identifier.as_deref() + } + + /// 获取资源路径(从资源标识符中提取路径部分) + /// 例如:POST+/environments -> /environments + pub fn resource_path(&self) -> Option<&str> { + self.resource_identifier + .as_ref() + .and_then(|id| id.split_once('+').map(|(_, path)| path)) + } +} diff --git a/src-tauri/crates/business/src/svc_ctx.rs b/src-tauri/crates/business/src/svc_ctx.rs new file mode 100644 index 00000000..434aa9ce --- /dev/null +++ b/src-tauri/crates/business/src/svc_ctx.rs @@ -0,0 +1,132 @@ +use crate::{ + database::{self, DbPool}, + entitys::CreateWorkspaceRequest, + utils::{DatabaseConfig, WorkspaceQuotaConfig}, +}; +use std::sync::{Arc, RwLock}; +use uuid::Uuid; + +/// Shared resources used by handlers and services. +#[derive(Clone)] +pub struct SvcCtx { + pub db: DbPool, + pub workspace_quota: WorkspaceQuotaConfig, + pub local_user_uuid: Uuid, + session_user_uuid: Arc>>, +} + +impl SvcCtx { + pub async fn new(config: &DatabaseConfig) -> Result { + let db = Self::create_db(config).await?; + database::migrate(&db).await?; + crate::services::browser_kernels::import_default_catalog(&db) + .await + .map_err(anyhow::Error::msg)?; + crate::services::browser_kernels::migrate_legacy_environment_bindings(&db) + .await + .map_err(anyhow::Error::msg)?; + + let local_user_uuid = Self::ensure_local_user(&db).await?; + let context = Self { + db, + workspace_quota: WorkspaceQuotaConfig::default(), + local_user_uuid, + session_user_uuid: Arc::new(RwLock::new(None)), + }; + context.ensure_local_workspace().await?; + Ok(context) + } + + /// Build a request-scoped context for the currently authenticated local user. + /// The database pool and quota configuration stay shared between all users. + pub fn for_current_user(&self) -> Result { + let user_uuid = self + .current_user_uuid() + .ok_or_else(|| anyhow::anyhow!("No local user is authenticated"))?; + Ok(self.for_user(user_uuid)) + } + + pub fn for_user(&self, user_uuid: Uuid) -> Self { + Self { + db: self.db.clone(), + workspace_quota: self.workspace_quota.clone(), + local_user_uuid: user_uuid, + session_user_uuid: self.session_user_uuid.clone(), + } + } + + pub fn current_user_uuid(&self) -> Option { + *self.session_user_uuid.read().expect("local user session lock poisoned") + } + + pub fn authenticate_user(&self, user_uuid: Uuid) { + *self.session_user_uuid.write().expect("local user session lock poisoned") = + Some(user_uuid); + } + + pub fn clear_authenticated_user(&self) { + *self.session_user_uuid.write().expect("local user session lock poisoned") = None; + } + + pub async fn create_db(config: &DatabaseConfig) -> Result { + database::connect(config).await + } + + async fn ensure_local_user(db: &DbPool) -> Result { + if let Some(uuid) = sqlx::query_scalar::<_, Uuid>( + "SELECT uuid FROM users WHERE id = 'LOCAL' AND deleted_at IS NULL LIMIT 1", + ) + .fetch_optional(db) + .await? + { + sqlx::query( + "INSERT INTO local_user_auth (user_uuid, avatar) VALUES ($1, '🙂') \ + ON CONFLICT (user_uuid) DO NOTHING", + ) + .bind(uuid) + .execute(db) + .await?; + return Ok(uuid); + } + + let uuid = Uuid::new_v4(); + let mut tx = db.begin().await?; + sqlx::query("INSERT INTO users (uuid, id) VALUES ($1, 'LOCAL')") + .bind(uuid) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO user_infos (user_uuid, nickname, email, password) VALUES ($1, $2, $3, '')", + ) + .bind(uuid) + .bind("Local User") + .bind(format!("local-{uuid}@simprint.invalid")) + .execute(&mut *tx) + .await?; + sqlx::query("INSERT INTO local_user_auth (user_uuid, avatar) VALUES ($1, '🙂')") + .bind(uuid) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(uuid) + } + + async fn ensure_local_workspace(&self) -> Result<(), anyhow::Error> { + if crate::models::workspaces::fetch_user_workspaces(&self.db, self.local_user_uuid) + .await? + .is_empty() + { + crate::services::workspaces::create_workspace_service( + self, + self.local_user_uuid, + &CreateWorkspaceRequest { + name: "Local Workspace".to_string(), + workspace_type: Some("personal".to_string()), + }, + ) + .await + .map_err(anyhow::Error::msg)?; + } + Ok(()) + } +} diff --git a/src-tauri/crates/business/src/utils.rs b/src-tauri/crates/business/src/utils.rs new file mode 100644 index 00000000..aa4926f4 --- /dev/null +++ b/src-tauri/crates/business/src/utils.rs @@ -0,0 +1,3 @@ +mod config; + +pub use config::*; diff --git a/src-tauri/crates/business/src/utils/config.rs b/src-tauri/crates/business/src/utils/config.rs new file mode 100644 index 00000000..bc0c3784 --- /dev/null +++ b/src-tauri/crates/business/src/utils/config.rs @@ -0,0 +1,64 @@ +use serde::Deserialize; +use std::path::Path; + +/// Connection settings for the embedded SQLite database. +#[derive(Debug, Clone, Deserialize)] +pub struct DatabaseConfig { + pub url: String, + pub max_connections: u32, + pub min_connections: u32, + pub max_lifetime: u64, + pub acquire_timeout: u64, + pub idle_timeout: u64, +} + +impl DatabaseConfig { + pub fn embedded(url: impl Into) -> Self { + Self { + url: url.into(), + max_connections: 4, + min_connections: 1, + max_lifetime: 30 * 60, + acquire_timeout: 30, + idle_timeout: 10 * 60, + } + } + + pub fn from_path(path: &Path) -> Self { + let normalized = path.to_string_lossy().replace('\\', "/"); + Self::embedded(format!("sqlite://{normalized}")) + } +} + +/// Default limits used when a local workspace is created. +#[derive(Debug, Clone, Deserialize)] +pub struct WorkspaceQuotaConfig { + pub default: WorkspaceQuotaValues, +} + +impl Default for WorkspaceQuotaConfig { + fn default() -> Self { + Self { + default: WorkspaceQuotaValues::default(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WorkspaceQuotaValues { + pub max_environments: i32, + pub max_team_members: i32, + pub max_proxies: i32, + pub max_rpa_tasks: i32, +} + +impl Default for WorkspaceQuotaValues { + fn default() -> Self { + Self { + max_environments: 8, + max_team_members: 1, + max_proxies: 99_999, + max_rpa_tasks: 99_999, + } + } +} diff --git a/src-tauri/src/app/components/tray.rs b/src-tauri/src/app/components/tray.rs index 6ad4e369..60b7e20b 100644 --- a/src-tauri/src/app/components/tray.rs +++ b/src-tauri/src/app/components/tray.rs @@ -93,11 +93,6 @@ pub fn tray_handler(tray: &TrayIcon, event: TrayIconEvent) { let _ = window.set_focus(); } } else { - // 应用未准备好,可以选择显示splashscreen或忽略 - if let Some(splash_window) = app_handle.get_webview_window("splashscreen") { - let _ = splash_window.show(); - let _ = splash_window.set_focus(); - } println!("应用正在初始化中,请稍候..."); } }); diff --git a/src-tauri/src/app/context.rs b/src-tauri/src/app/context.rs index 01eac4f1..3cc9a2ef 100644 --- a/src-tauri/src/app/context.rs +++ b/src-tauri/src/app/context.rs @@ -9,9 +9,6 @@ use std::sync::Arc; use tokio::sync::OnceCell; use crate::app::runtime::SimprintRuntimeManager; -use crate::core::config::AppConfig; -use crate::infrastructure::http::encryption::RsaSecret; -use crate::infrastructure::main_server::client::MainServerRequestClient; use crate::local_api::LocalApiManager; use crate::mcp::McpManager; use crate::services::environment::{EnvironmentPositionManager, EnvironmentStatusManager}; @@ -21,21 +18,12 @@ use crate::services::mihomo::MihomoManager; /// /// 包含所有需要全局访问的状态和服务 pub struct AppContext { - /// 应用配置(不可变) - pub config: AppConfig, - - /// RSA 密钥对(用于 HTTP 加密) - pub rsa_keypair: Arc, - /// 环境状态管理器 pub env_status_manager: Arc, /// 环境位置管理器 pub env_position_manager: Arc, - /// 主服务器 HTTP 客户端 - pub main_server_client: Arc, - /// 本地 API 服务管理器 pub local_api_manager: Arc, @@ -54,30 +42,7 @@ static APP_CONTEXT: OnceCell> = OnceCell::const_new(); impl AppContext { /// 创建新的应用上下文(早期初始化,不依赖 AppHandle) - pub fn new(config: AppConfig) -> anyhow::Result { - // 初始化 RSA 密钥对 - let rsa_keypair = Arc::new(RsaSecret::new()?); - - // 初始化主服务器 HTTP 客户端并设置拦截器 - let mut main_server_client = MainServerRequestClient::new(); - - // 请求拦截器 - main_server_client.before(|rb| { - Box::pin(crate::infrastructure::main_server::interceptors::request::encrypt(rb)) - }); - main_server_client.before(|rb| { - Box::pin(crate::infrastructure::main_server::interceptors::request::auth(rb)) - }); - - // 响应拦截器 - main_server_client.after(|response| { - Box::pin( - crate::infrastructure::main_server::interceptors::response_interceptor(response), - ) - }); - - let main_server_client = Arc::new(main_server_client); - + pub fn new() -> anyhow::Result { // 初始化环境状态管理器 let env_status_manager = Arc::new(EnvironmentStatusManager::new()); @@ -97,11 +62,8 @@ impl AppContext { let simprint_runtime_manager = Arc::new(SimprintRuntimeManager::new()); Ok(Self { - config, - rsa_keypair, env_status_manager, env_position_manager, - main_server_client, local_api_manager, mcp_manager, mihomo_manager, @@ -110,19 +72,14 @@ impl AppContext { } /// 初始化全局上下文(早期阶段) - pub fn init_early(config: AppConfig) -> anyhow::Result<&'static Arc> { - let context = Arc::new(Self::new(config)?); + pub fn init_early() -> anyhow::Result<&'static Arc> { + let context = Arc::new(Self::new()?); APP_CONTEXT .set(context) .map_err(|_| anyhow::anyhow!("AppContext already initialized"))?; Ok(APP_CONTEXT.get().unwrap()) } - /// 获取应用配置 - pub fn config(&self) -> &AppConfig { - &self.config - } - /// 获取全局上下文(如果未初始化则 panic) pub fn get() -> &'static Arc { APP_CONTEXT.get().expect("AppContext not initialized") diff --git a/src-tauri/src/app/lifecycle.rs b/src-tauri/src/app/lifecycle.rs index def2d6f3..e74e512f 100644 --- a/src-tauri/src/app/lifecycle.rs +++ b/src-tauri/src/app/lifecycle.rs @@ -5,17 +5,12 @@ use anyhow::Result; use tauri::AppHandle; use crate::app::context::AppContext; -use crate::core::config; /// 初始化应用核心组件(早期阶段,不依赖 Tauri) /// /// 按照正确的依赖顺序初始化所有组件 pub fn init_early() -> Result<()> { - // 1. 加载配置(最先初始化,其他组件依赖配置) - let app_config = config::get_or_err()?.clone(); - - // 2. 初始化应用上下文(包含 RSA、HTTP 客户端、runtime 管理器) - AppContext::init_early(app_config)?; + AppContext::init_early()?; Ok(()) } diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index 00aaa4d7..e7750aa1 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -8,11 +8,48 @@ pub mod runtime; pub mod runtime_info; pub mod session_lock; pub mod setup; -pub mod splashscreen; pub mod startup; use crate::commands; use components::tray; +use tauri::Manager; + +fn initialize_business_context( + database_config: business::utils::DatabaseConfig, + user_kernel_catalog: std::path::PathBuf, +) -> anyhow::Result { + // Tauri invokes `setup` from its async runtime. Blocking that same thread with + // `tauri::async_runtime::block_on` would try to enter Tokio recursively and panic. + // Keep setup synchronous, but perform the one-time async database bootstrap from + // a plain OS thread using Tauri's runtime handle. + std::thread::Builder::new() + .name("business-database-init".to_string()) + .spawn(move || { + tauri::async_runtime::block_on(async move { + let context = business::svc_ctx::SvcCtx::new(&database_config).await?; + let imported = business::services::browser_kernels::import_catalog_file( + &context.db, + &user_kernel_catalog, + ) + .await + .map_err(anyhow::Error::msg)?; + let migrated = + business::services::browser_kernels::migrate_legacy_environment_bindings( + &context.db, + ) + .await + .map_err(anyhow::Error::msg)?; + if imported > 0 || migrated > 0 { + log::info!( + "Imported {imported} user browser kernel records and migrated {migrated} environment bindings" + ); + } + Ok(context) + }) + })? + .join() + .map_err(|_| anyhow::anyhow!("Local business database initialization thread panicked"))? +} pub fn run() { let ctx = tauri::generate_context!(); @@ -29,9 +66,27 @@ pub fn run() { crate::infrastructure::persistence::tauri_store::get_logs_path(app.handle()) .map_err(|e| anyhow::anyhow!("{}", e))?; crate::core::logger::init_logging(&log_dir); + + let database_file = crate::core::paths::PathManager::get_business_database_file()?; + let database_config = business::utils::DatabaseConfig::from_path(&database_file); + let user_kernel_catalog = + crate::core::paths::PathManager::get_config_dir()?.join("browser-kernels.json"); + let business_context = + initialize_business_context(database_config, user_kernel_catalog.clone())?; + app.manage(business_context); + log::info!( + "Local business database initialized: {}", + database_file.display() + ); + log::info!( + "Optional user browser kernel catalog: {}", + user_kernel_catalog.display() + ); + setup::register_deep_link(app.handle().clone())?; - crate::commands::window::create_splashscreen_window(app.handle().clone())?; + crate::commands::window::create_main_window(app.handle().clone())?; + log::info!("Hidden main window created for single-window startup"); tray::menu(app)?; @@ -46,8 +101,9 @@ pub fn run() { session_lock_manager.clone(), ); - // 初始化应用启动流程(显示 splashscreen) - splashscreen::init_startup(app.handle().clone()); + if startup::StartupService::backend_startup_ready(app.handle()).is_err() { + return Err(anyhow::anyhow!("Failed to complete the backend startup gate").into()); + } Ok(()) }) diff --git a/src-tauri/src/app/runtime.rs b/src-tauri/src/app/runtime.rs index 8b22975b..d27a5732 100644 --- a/src-tauri/src/app/runtime.rs +++ b/src-tauri/src/app/runtime.rs @@ -291,26 +291,27 @@ fn runtime_err_to_string( } fn current_auth_info() -> AuthInfo { - use crate::infrastructure::persistence::credential::{get_credential, is_login}; - - if !is_login() { + let Some(user_uuid) = crate::commands::auth::authenticated_user_uuid() else { return AuthInfo { is_authenticated: false, access_token: None, user_info: None, }; - } + }; - let credential = get_credential(); AuthInfo { is_authenticated: true, - access_token: credential.get_access_token(), - user_info: None, + access_token: None, + user_info: Some(crate::infrastructure::runtime::UserInfo { + user_id: user_uuid.to_string(), + username: user_uuid.to_string(), + email: None, + }), } } fn is_runtime_authenticated() -> bool { - crate::infrastructure::persistence::credential::is_login() + crate::commands::auth::has_local_session() } impl Default for SimprintRuntimeManager { diff --git a/src-tauri/src/app/setup.rs b/src-tauri/src/app/setup.rs index 270c4991..cb7d3011 100644 --- a/src-tauri/src/app/setup.rs +++ b/src-tauri/src/app/setup.rs @@ -11,12 +11,15 @@ pub fn register_plugins(app_handle: &AppHandle) { .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { use tauri::Manager; - if let Some(main_window) = app.get_webview_window("main") { + let app_state = crate::app::init_state::read_app_init_state(); + if app_state.is_initialized && !app_state.is_updating { + let Some(main_window) = app.get_webview_window("main") else { + return; + }; + // 如果程序启动期间得到深度链接参数,则将事件传递并传递打开的链接给前端。 if let Some(arg_1) = argv.get(1) { if arg_1.contains("://") { - // 收到 deep link 时,先在本地持久化 referral_code(若有) - crate::infrastructure::deeplink::process_arg(arg_1); main_window.emit("deep-link-open", arg_1).unwrap(); } @@ -32,7 +35,10 @@ pub fn register_plugins(app_handle: &AppHandle) { // Register process plugin app_handle.plugin(tauri_plugin_process::init()).unwrap(); - app_handle.plugin(tauri_plugin_upload::init()).unwrap(); + // Register Tauri's signed updater. Release builds inject the public key + // into tauri.conf.json before compilation. + #[cfg(desktop)] + app_handle.plugin(tauri_plugin_updater::Builder::new().build()).unwrap(); // deep-link 插件 app_handle.plugin(tauri_plugin_deep_link::init()).unwrap(); @@ -70,24 +76,6 @@ pub fn register_deep_link(app: AppHandle) -> Result<(), anyhow::Error> { Ok(()) } -/// 后台初始化服务器公钥 -pub fn init_server_public_key_background(app_handle: AppHandle) { - tauri::async_runtime::spawn(async move { - match crate::infrastructure::persistence::credential::init_server_public_key().await { - Ok(_) => { - log::info!("Server connection successful"); - // 通知前端:服务器连接成功 - let _ = app_handle.emit("server-connected", ()); - } - Err(e) => { - log::warn!("Server connection failed: {}", e); - // 通知前端:服务器连接失败 - let _ = app_handle.emit("server-connection-failed", e); - } - } - }); -} - pub fn init_simprint_runtime_background(app_handle: AppHandle) { tauri::async_runtime::spawn(async move { use crate::app::context::AppContext; diff --git a/src-tauri/src/app/splashscreen.rs b/src-tauri/src/app/splashscreen.rs deleted file mode 100644 index ffc27475..00000000 --- a/src-tauri/src/app/splashscreen.rs +++ /dev/null @@ -1,271 +0,0 @@ -use crate::commands::updater; -use std::ffi::OsStr; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use tauri::{AppHandle, Emitter, Manager}; - -const SKIP_UPDATE_ARG: &str = "--skip-update"; - -fn has_skip_update_arg(args: I) -> bool -where - I: IntoIterator, - S: AsRef, -{ - args.into_iter().any(|arg| arg.as_ref() == OsStr::new(SKIP_UPDATE_ARG)) -} - -fn should_skip_update() -> bool { - has_skip_update_arg(std::env::args_os()) -} - -// 前端就绪标志 -static SPLASHSCREEN_FRONTEND_READY: std::sync::OnceLock> = - std::sync::OnceLock::new(); - -/// 获取前端就绪标志 -fn get_frontend_ready_flag() -> Arc { - SPLASHSCREEN_FRONTEND_READY - .get_or_init(|| Arc::new(AtomicBool::new(false))) - .clone() -} - -/// 检查前端是否已就绪 -fn is_frontend_ready() -> bool { - get_frontend_ready_flag().load(Ordering::Acquire) -} - -/// 设置前端已就绪 -pub fn set_frontend_ready() { - get_frontend_ready_flag().store(true, Ordering::Release); -} - -/// 发射加载进度事件到 splashscreen -fn emit_progress(app_handle: &AppHandle, progress: u8, text: &str, status: Option<&str>) { - if let Some(splash_window) = app_handle.get_webview_window("splashscreen") { - #[derive(serde::Serialize, Clone)] - struct ProgressPayload { - progress: u8, - text: String, - status: Option, - } - - let _ = splash_window.emit( - "splashscreen-progress", - ProgressPayload { - progress, - text: text.to_string(), - status: status.map(|s| s.to_string()), - }, - ); - } -} - -/// 发射状态完成事件 -fn emit_status_complete(app_handle: &AppHandle, status: &str) { - if let Some(splash_window) = app_handle.get_webview_window("splashscreen") { - #[derive(serde::Serialize, Clone)] - struct StatusCompletePayload { - status: String, - } - - let _ = splash_window.emit( - "splashscreen-status-complete", - StatusCompletePayload { - status: status.to_string(), - }, - ); - } -} - -/// 发射加载完成事件 -fn emit_ready(app_handle: &AppHandle) { - // 向 splashscreen 窗口发送加载完成事件 - if let Some(splash_window) = app_handle.get_webview_window("splashscreen") { - let _ = splash_window.emit("splashscreen-ready", ()); - } - // 向主窗口发送加载完成事件,通知主窗口加载逻辑已完成 - if let Some(main_window) = app_handle.get_webview_window("main") { - let _ = main_window.emit("splashscreen-loading-complete", ()); - } -} - -/// 发射连接失败事件(公钥初始化失败时调用,停止后续流程) -fn emit_connection_failed(app_handle: &AppHandle) { - if let Some(splash_window) = app_handle.get_webview_window("splashscreen") { - let _ = splash_window.emit("splashscreen-connection-failed", ()); - } -} - -/// 初始化应用启动流程(显示 splashscreen 并控制加载) -pub fn init_startup(app_handle: AppHandle) { - // 注意:splashscreen 窗口由前端控制显示,确保内容准备好后再显示 - // 窗口在 tauri.conf.json 中配置为 visible: false,前端会在内容准备好后调用 show() - - // 异步执行加载流程 - tauri::async_runtime::spawn(async move { - let app_handle_clone = app_handle.clone(); - - // 等待前端通知准备就绪(无限期等待,直到前端调用 splashscreen_ready) - while !is_frontend_ready() { - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - } - - // 步骤1: 初始化应用 - emit_progress(&app_handle_clone, 10, "正在初始化...", Some("init")); - tokio::time::sleep(tokio::time::Duration::from_millis(300)).await; - emit_status_complete(&app_handle_clone, "init"); - - // 步骤2: 加载配置 - emit_progress(&app_handle_clone, 30, "加载配置中...", Some("config")); - tokio::time::sleep(tokio::time::Duration::from_millis(400)).await; - emit_status_complete(&app_handle_clone, "config"); - - // 步骤3: 初始化安全上下文 - emit_progress( - &app_handle_clone, - 50, - "初始化安全上下文...", - Some("security"), - ); - tokio::time::sleep(tokio::time::Duration::from_millis(300)).await; - emit_status_complete(&app_handle_clone, "security"); - - // 步骤4: 连接服务器 - emit_progress(&app_handle_clone, 70, "连接服务器...", Some("server")); - - // 等待服务器连接(使用现有的后台初始化) - // 这里我们等待一段时间,实际连接由 init_server_public_key_background 处理 - tokio::time::sleep(tokio::time::Duration::from_millis(800)).await; - - // 检查服务器连接状态 - if let Err(e) = - crate::infrastructure::persistence::credential::init_server_public_key().await - { - log::warn!("Server connection failed: {}", e); - emit_status_complete(&app_handle_clone, "server"); - emit_progress(&app_handle_clone, 90, "服务器连接失败", None); - // 发送连接失败事件,停止后续流程 - emit_connection_failed(&app_handle_clone); - // 不再继续后续步骤(不创建主窗口,不发送 ready 事件) - return; - } - - log::info!("Server connection successful"); - emit_status_complete(&app_handle_clone, "server"); - emit_progress(&app_handle_clone, 90, "服务器连接成功", None); - - // 步骤4.1: 检查并处理更新(自动检查、下载、安装) - // 跳过参数只在更新入口消费,不传入更新服务、下载器或安装器。 - if should_skip_update() { - log::info!("Skipping startup update flow because {SKIP_UPDATE_ARG} was provided"); - emit_progress( - &app_handle_clone, - 92, - "已跳过更新检查", - Some("update-check"), - ); - emit_status_complete(&app_handle_clone, "update-check"); - } else { - emit_progress(&app_handle_clone, 92, "检查更新...", Some("update-check")); - let updates_available = match updater::check_updates(app_handle_clone.clone()).await { - Ok(result) => result.has_updates, - Err(e) => { - log::error!("Update check failed: {}", e); - emit_progress( - &app_handle_clone, - 92, - "检查更新失败,继续启动", - Some("update-check"), - ); - false - } - }; - - if updates_available { - emit_progress( - &app_handle_clone, - 94, - "检测到更新,开始下载...", - Some("update-download"), - ); - match updater::download_updates(app_handle_clone.clone(), None).await { - Ok(download_result) => { - if download_result.success_count > 0 { - emit_progress( - &app_handle_clone, - 96, - "下载完成,准备安装", - Some("update-install"), - ); - // 触发安装并退出(updater.exe 负责后续重启) - if let Err(e) = - updater::start_update_install(app_handle_clone.clone()).await - { - log::error!("Update installation start failed: {}", e); - emit_progress( - &app_handle_clone, - 96, - "安装启动失败,继续当前版本", - Some("update-install"), - ); - } - // 无论安装启动是否成功,都不再继续创建主窗口,交由 updater.exe 或用户重启 - return; - } else { - log::warn!("Update download failed, continuing with current version"); - emit_progress( - &app_handle_clone, - 94, - "下载失败,继续启动当前版本", - Some("update-download"), - ); - } - } - Err(e) => { - log::error!("Update download error: {}", e); - emit_progress( - &app_handle_clone, - 94, - "下载更新失败,继续启动当前版本", - Some("update-download"), - ); - } - } - } - } - - // 创建主窗口(在步骤4完成后) - if let Err(e) = crate::commands::window::create_main_window(app_handle_clone.clone()).await - { - log::error!("Failed to create main window: {}", e); - // 不阻止加载流程继续 - } - - // 步骤5: 准备就绪 - emit_progress(&app_handle_clone, 100, "准备就绪", Some("ready")); - tokio::time::sleep(tokio::time::Duration::from_millis(300)).await; - emit_status_complete(&app_handle_clone, "ready"); - - // 发射加载完成事件(前端会自动关闭 splashscreen 并显示主窗口) - emit_ready(&app_handle_clone); - }); -} - -#[cfg(test)] -mod tests { - use super::has_skip_update_arg; - - #[test] - fn detects_skip_update_argument() { - assert!(has_skip_update_arg(["simprint.exe", "--skip-update"])); - } - - #[test] - fn does_not_treat_other_arguments_as_skip_update() { - assert!(!has_skip_update_arg([ - "simprint.exe", - "--skip-update-check", - "simprint://open" - ])); - } -} diff --git a/src-tauri/src/app/startup.rs b/src-tauri/src/app/startup.rs index 81a2d0c0..2c719f47 100644 --- a/src-tauri/src/app/startup.rs +++ b/src-tauri/src/app/startup.rs @@ -1,11 +1,17 @@ use crate::app::init_state::AppInitState; use serde::Deserialize; +use std::sync::atomic::{AtomicBool, Ordering}; use tauri::{AppHandle, Manager}; pub struct StartupService; const GENERAL_SETTINGS_STORE_KEY: &str = "general"; +/// 启动完成采用双就绪门闩:后端流程与主窗口前端必须都真实就绪。 +static BACKEND_STARTUP_READY: AtomicBool = AtomicBool::new(false); +static MAIN_FRONTEND_READY: AtomicBool = AtomicBool::new(false); +static STARTUP_COMPLETED: AtomicBool = AtomicBool::new(false); + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct GeneralSettingsSnapshot { @@ -19,8 +25,51 @@ fn should_start_minimized(app: &AppHandle) -> bool { .unwrap_or(false) } +fn try_complete_startup(app: &AppHandle) -> Result<(), ()> { + let backend_ready = BACKEND_STARTUP_READY.load(Ordering::Acquire); + let frontend_ready = MAIN_FRONTEND_READY.load(Ordering::Acquire); + + if !backend_ready || !frontend_ready { + log::info!( + "Startup gate waiting: backend_ready={backend_ready}, main_frontend_ready={frontend_ready}" + ); + return Ok(()); + } + + if app.get_webview_window("main").is_none() { + log::error!("Startup gates are ready, but the main window does not exist"); + return Err(()); + } + + if STARTUP_COMPLETED + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Ok(()); + } + + log::info!("Startup gates satisfied; completing startup"); + + let mut app_state = AppInitState::default(); + app_state.is_initialized = true; + app_state.is_updating = false; + let _ = crate::app::init_state::update_app_init_state(app_state); + + if let Some(main_window) = app.get_webview_window("main") { + if should_start_minimized(app) { + log::info!("Start minimized is enabled; keeping the ready main window hidden"); + } else { + let _ = main_window.show(); + let _ = main_window.set_focus(); + log::info!("Main window shown after both startup gates became ready"); + } + } + + Ok(()) +} + impl StartupService { - /// 设置应用为更新状态 + /// 设置应用为更新状态。 pub async fn set_updating_state() -> Result<(), ()> { let mut app_state = AppInitState::default(); app_state.is_updating = true; @@ -31,58 +80,42 @@ impl StartupService { Ok(()) } - /// 获取应用状态 + /// 获取应用状态。 pub async fn get_app_state() -> Result { - let app_state = crate::app::init_state::read_app_init_state(); - Ok(app_state) + Ok(crate::app::init_state::read_app_init_state()) } - /// 完成加载并显示主窗口(关闭加载窗口并显示主窗口) - pub async fn complete_and_show_main(app: AppHandle) -> Result<(), ()> { - // 设置为已初始化状态 - { - let mut app_state = AppInitState::default(); - app_state.is_initialized = true; - app_state.is_updating = false; - let _ = crate::app::init_state::update_app_init_state(app_state); - } - - // 关闭 splashscreen 窗口 - if let Some(splash_window) = app.get_webview_window("splashscreen") { - let _ = splash_window.close(); - } - - // 按配置决定是否显示主窗口 - if let Some(main_window) = app.get_webview_window("main") { - if should_start_minimized(&app) { - log::info!("启动时最小化已启用,主窗口保持隐藏"); - } else { - let _ = main_window.show(); - let _ = main_window.set_focus(); - log::info!("主窗口已显示"); - } - } + /// 记录隐藏主窗口的前端已经完成插件、认证、路由、字体与首帧布局。 + pub async fn main_window_ready(app: AppHandle) -> Result<(), ()> { + MAIN_FRONTEND_READY.store(true, Ordering::Release); + log::info!("Main window frontend reported ready"); + try_complete_startup(&app) + } - Ok(()) + /// 记录后端启动流程已经完成,并尝试与前端就绪状态汇合。 + pub fn backend_startup_ready(app: &AppHandle) -> Result<(), ()> { + BACKEND_STARTUP_READY.store(true, Ordering::Release); + log::info!("Backend startup flow reported ready"); + try_complete_startup(app) } - /// 显示主窗口(由前端在内容渲染完成后调用) + /// 显示已经完成初始化的主窗口。 pub async fn show_main_window(app: AppHandle) -> Result<(), ()> { + let app_state = crate::app::init_state::read_app_init_state(); + if !app_state.is_initialized || app_state.is_updating { + log::warn!("Ignored a request to show the main window before startup completed"); + return Ok(()); + } + if let Some(main_window) = app.get_webview_window("main") { if should_start_minimized(&app) { - log::info!("启动时最小化已启用,跳过主窗口显示"); + log::info!("Start minimized is enabled; skipping main window display"); } else { let _ = main_window.show(); let _ = main_window.set_focus(); - log::info!("主窗口已显示"); + log::info!("Main window shown"); } } Ok(()) } - - /// 通知后端前端 splashscreen 已准备好接收事件 - pub async fn splashscreen_ready() -> Result<(), ()> { - crate::app::splashscreen::set_frontend_ready(); - Ok(()) - } } diff --git a/src-tauri/src/bin/updater.rs b/src-tauri/src/bin/updater.rs deleted file mode 100644 index 9fa3b123..00000000 --- a/src-tauri/src/bin/updater.rs +++ /dev/null @@ -1,232 +0,0 @@ -//! 更新器二进制入口点 -//! -//! 独立的更新程序,只负责执行文件替换 -//! install: 执行文件替换 - -// Prevents additional console window on Windows in release, DO NOT REMOVE!! -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -use simprint_lib::core::config; -use simprint_lib::core::logger; -use simprint_lib::infrastructure::updater::types::{ArtifactInfo, InstallTasks}; -use simprint_lib::infrastructure::updater::{installer, manifest, planner}; -use std::env; -use std::fs; -use std::path::PathBuf; -use std::process; - -/// 更新器主函数 -#[tokio::main] -async fn main() -> Result<(), Box> { - // 初始化日志系统(更新器无 Tauri/store,使用兜底目录) - logger::init_logging(logger::bootstrap_log_dir()); - - // 解析命令行参数 - let args: Vec = env::args().collect(); - let mode = args.get(1).map(|s| s.as_str()).unwrap_or_else(|| { - process::exit(1); - }); - - match mode { - "install" => { - let tasks_file = args.get(2).cloned().unwrap_or_else(|| { - simprint_lib::core::paths::PathManager::get_update_tasks_file() - .unwrap_or_else(|_| PathBuf::from("update_tasks.json")) - .to_string_lossy() - .to_string() - }); - run_install(&tasks_file).await?; - } - "install-package" => { - let installer_path = args.get(2).cloned().unwrap_or_else(|| { - process::exit(1); - }); - run_install_package(&installer_path)?; - } - _ => { - process::exit(1); - } - } - - Ok(()) -} - -fn run_install_package(installer_path: &str) -> Result<(), Box> { - let installer = PathBuf::from(installer_path); - if !installer.exists() { - return Err(format!("安装包不存在: {}", installer.display()).into()); - } - - use std::process::Command; - - Command::new(&installer) - .arg("/P") - .arg("/UPDATE") - .arg("/R") - .spawn() - .map_err(|e| format!("启动安装包失败: {}", e))?; - - Ok(()) -} - -/// 安装模式 -/// -/// 从 JSON 文件读取安装任务,执行文件替换 -async fn run_install(tasks_file: &str) -> Result<(), Box> { - // 读取任务文件 - let tasks_content = fs::read_to_string(tasks_file) - .map_err(|e| format!("读取任务文件失败: {} - {}", tasks_file, e))?; - - let install_tasks: InstallTasks = serde_json::from_str(&tasks_content) - .map_err(|e| format!("解析任务文件失败: {} - {}", tasks_file, e))?; - - if install_tasks.tasks.is_empty() { - log::warn!("任务文件为空"); - return Ok(()); - } - - // 初始化配置(用于 manifest) - if let Err(e) = config::init() { - log::error!("配置初始化失败: {}", e); - return Err(format!("配置初始化失败: {}", e).into()); - } - - let mut success_count = 0; - let mut failed_count = 0; - let mut installed_artifacts: Vec = Vec::new(); - - // 遍历所有任务执行安装 - for install_task in &install_tasks.tasks { - let resource_name = install_task.resource_name.clone(); - - // 再次校验文件哈希(确保文件未被篡改) - let temp_path = PathBuf::from(&install_task.temp_path); - let actual_hash = planner::calculate_file_hash(&temp_path) - .map_err(|e| format!("计算文件哈希失败: {}", e))?; - - if actual_hash.to_lowercase() != install_task.expected_hash.to_lowercase() { - log::error!( - "文件哈希不匹配: {} (期望: {}, 实际: {})", - resource_name, - install_task.expected_hash, - actual_hash - ); - failed_count += 1; - continue; - } - - // 执行安装 - let target_path = PathBuf::from(&install_task.target_path); - let backup_path = install_task.backup_path.as_ref().map(|p| PathBuf::from(p)); - - match installer::install_file_direct( - &resource_name, - &target_path, - backup_path.as_deref(), - &temp_path, - ) { - Ok(_) => { - success_count += 1; - installed_artifacts.push(ArtifactInfo { - resource_name: resource_name.clone(), - version: install_task.version.clone(), - }); - } - Err(e) => { - log::error!("{} - 安装失败: {}", resource_name, e); - - // 尝试回滚 - if let Some(ref backup_path) = backup_path { - if backup_path.exists() { - let _ = installer::rollback_from_backup(&target_path, backup_path); - } - } - - failed_count += 1; - } - } - } - - // 更新 manifest.json - if !installed_artifacts.is_empty() { - let current_version = env!("CARGO_PKG_VERSION"); - - if let Err(e) = manifest::write_manifest(current_version, &installed_artifacts) { - log::error!("更新 manifest.json 失败: {}", e); - } - } - - // 清理任务文件 - if let Err(e) = fs::remove_file(tasks_file) { - log::warn!("删除任务文件失败: {}", e); - } - - // 设置最终状态 - if failed_count == 0 { - log::info!("安装完成,共更新 {} 个文件", success_count); - } else if success_count > 0 { - log::warn!( - "部分安装完成:成功 {}, 失败 {}", - success_count, - failed_count - ); - } else { - log::error!("所有安装失败"); - process::exit(1); - } - - // 检查是否更新了主程序,如果是则重启主程序 - if success_count > 0 { - if let Err(e) = restart_main_app() { - log::error!("重启主程序失败: {}", e); - } - } - - // 确保日志刷新 - log::logger().flush(); - - Ok(()) -} - -/// 重启主程序 -fn restart_main_app() -> Result<(), Box> { - // 获取当前可执行文件目录(updater 与主程序同目录) - let current_exe = env::current_exe()?; - let exe_dir = current_exe.parent().ok_or_else(|| "无法获取可执行文件目录")?; - - // 主程序名与 Cargo [package] name 一致,扩展名随平台(Windows: .exe) - let main_app_name = format!("{}{}", env!("CARGO_PKG_NAME"), std::env::consts::EXE_SUFFIX); - let main_app_exe = exe_dir.join(&main_app_name); - - if !main_app_exe.exists() { - return Err(format!("找不到主程序: {}", main_app_exe.display()).into()); - } - - // 使用 std::process::Command 启动主程序 - // 由于 updater.exe 已有管理员权限,可以直接启动需要管理员权限的主程序 - #[cfg(target_os = "windows")] - { - use std::process::Command; - use std::process::Stdio; - - Command::new(&main_app_exe) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| format!("启动主程序失败: {}", e))?; - } - - #[cfg(not(target_os = "windows"))] - { - use std::process::Command; - use std::process::Stdio; - - Command::new(&main_app_exe) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| format!("启动主程序失败: {}", e))?; - } - - Ok(()) -} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 855a8140..2b5173c3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -13,19 +13,17 @@ pub mod network; pub mod rpa; pub mod security; pub mod store; -pub mod updater; pub mod window; pub fn register_handles() -> impl Fn(Invoke) -> bool + Send + Sync + 'static { tauri::generate_handler![ // App commands - app::complete_and_show_main, + app::main_window_ready, app::show_main_window, app::get_auto_start_state, app::set_auto_start_enabled, app::set_updating_state, app::get_app_state, - app::splashscreen_ready, app::close_program, app::get_executable_path, app::is_dev, @@ -62,38 +60,24 @@ pub fn register_handles() -> impl Fn(Invoke) -> bool + Send + Sync + file_system::get_storage_default_paths, file_system::get_directory_sizes, // Security commands - security::get_client_public_key, security::report_user_activity, security::get_session_lock_state, security::unlock_session, // Network commands - network::http_get, network::http_post, - network::http_post_form, - network::http_put, - network::http_delete, network::test_proxy, network::test_direct_ip, network::detect_proxy_ip, network::download_files, rpa::execute_local_rpa_script, // Auth commands - auth::login, - auth::register, + auth::list_local_users, + auth::create_local_user, + auth::login_local_user, + auth::get_current_local_user, + auth::verify_local_user_password, auth::logout, - auth::save_credential, - auth::get_access_token, auth::is_logged_in, - auth::save_remembered_credential, - auth::get_remembered_credential, - auth::clear_remembered_credential, - // Updater commands - updater::check_update_available, - updater::check_updates, - updater::download_updates, - updater::start_update_install, - updater::start_prepared_update_install, - updater::get_prepared_update, // Core utilities crate::core::utils::process::kill_process, crate::core::utils::process::find_process, diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 868bffc9..078fdfc8 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -24,10 +24,10 @@ pub async fn get_app_state() -> Result { } #[tauri::command] -pub async fn complete_and_show_main(app: AppHandle) -> Result<()> { - crate::app::startup::StartupService::complete_and_show_main(app) +pub async fn main_window_ready(app: AppHandle) -> Result<()> { + crate::app::startup::StartupService::main_window_ready(app) .await - .map_err(|_| "显示主窗口失败".into()) + .map_err(|_| "记录主窗口就绪状态失败".into()) } #[tauri::command] @@ -47,13 +47,6 @@ pub fn set_auto_start_enabled(app: AppHandle, enabled: bool) -> Result Result<()> { - crate::app::startup::StartupService::splashscreen_ready() - .await - .map_err(|_| "设置 splashscreen 就绪失败".into()) -} - // ============================================================================ // 运行时信息命令 // ============================================================================ diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index 4477bd84..d5a2faa8 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -1,80 +1,89 @@ -/// 认证模块命令 -/// -/// 命令层仅负责参数解析和响应,业务逻辑由服务层处理 -use crate::core::error::Result; -use crate::infrastructure::http::client::JsonRespnse; -use crate::services::auth::{CredentialService, LoginService, RegisterService}; +use business::services::local_users::{CreateLocalUserRequest, LocalUser, LoginLocalUserRequest}; -// 重导出类型供外部使用 -pub use crate::services::auth::{ - BasicLoginRequest, LoginResponse, LoginType, RegisterRequest, RememberPasswordLoginRequest, -}; +use crate::app::context::AppContext; -// ============================================================================ -// 登录相关命令 -// ============================================================================ - -/// 登录命令 #[tauri::command] -pub async fn login(payload: LoginType) -> Result { - LoginService::login(payload).await +pub async fn list_local_users( + context: tauri::State<'_, business::svc_ctx::SvcCtx>, +) -> Result, String> { + business::services::local_users::list_local_users(&context).await } -/// 保存凭证命令 #[tauri::command] -pub async fn save_credential( - access_token: Option, - refresh_token: Option, -) -> Result<()> { - LoginService::save_credential(access_token, refresh_token).await +pub async fn create_local_user( + payload: CreateLocalUserRequest, + context: tauri::State<'_, business::svc_ctx::SvcCtx>, +) -> Result { + let user = business::services::local_users::create_local_user(&context, &payload).await?; + context.authenticate_user(user.uuid); + sync_local_session().await?; + Ok(user) } -// ============================================================================ -// 注册相关命令 -// ============================================================================ - -/// 注册命令 #[tauri::command] -pub async fn register(payload: RegisterRequest) -> Result { - RegisterService::register(payload).await +pub async fn login_local_user( + payload: LoginLocalUserRequest, + context: tauri::State<'_, business::svc_ctx::SvcCtx>, +) -> Result { + let user = business::services::local_users::authenticate_local_user(&context, &payload).await?; + sync_local_session().await?; + Ok(user) } -// ============================================================================ -// 凭证管理命令 -// ============================================================================ - -/// 退出登录 #[tauri::command] -pub async fn logout() -> Result<()> { - CredentialService::logout().await +pub async fn get_current_local_user( + context: tauri::State<'_, business::svc_ctx::SvcCtx>, +) -> Result, String> { + business::services::local_users::current_local_user(&context).await } -/// 获取登录凭证(access_token) #[tauri::command] -pub async fn get_access_token() -> Result { - CredentialService::get_access_token() +pub async fn verify_local_user_password( + password: Option, + context: tauri::State<'_, business::svc_ctx::SvcCtx>, +) -> Result<(), String> { + let user_uuid = context.current_user_uuid().ok_or_else(|| "尚未选择本地用户".to_string())?; + business::services::local_users::verify_local_user_password( + &context, + user_uuid, + password.as_deref(), + ) + .await } -/// 检查是否已登录 #[tauri::command] -pub async fn is_logged_in() -> Result { - Ok(CredentialService::is_logged_in()) +pub async fn logout(context: tauri::State<'_, business::svc_ctx::SvcCtx>) -> Result<(), String> { + context.clear_authenticated_user(); + if let Some(app_context) = AppContext::try_get() { + app_context.mcp_manager.stop().await; + app_context.local_api_manager.stop().await; + app_context.simprint_runtime_manager.stop().await; + } + Ok(()) } -/// 保存记住的凭证(用于"记住密码"功能) #[tauri::command] -pub async fn save_remembered_credential(email: String, refresh_token: String) -> Result<()> { - CredentialService::save_remembered_credential(email, refresh_token) +pub fn is_logged_in(context: tauri::State<'_, business::svc_ctx::SvcCtx>) -> bool { + context.current_user_uuid().is_some() } -/// 获取记住的凭证(用于自动登录) -#[tauri::command] -pub async fn get_remembered_credential() -> Result> { - CredentialService::get_remembered_credential() +async fn sync_local_session() -> Result<(), String> { + if let Some(context) = AppContext::try_get() { + context + .simprint_runtime_manager + .sync_session_state() + .await + .map_err(|error| error.to_string())?; + } + Ok(()) } -/// 清除记住的凭证 -#[tauri::command] -pub async fn clear_remembered_credential() -> Result<()> { - CredentialService::clear_remembered_credential() +pub(crate) fn authenticated_user_uuid() -> Option { + use tauri::Manager; + let app = crate::app::handle::get_app_handle().ok()?; + app.state::().current_user_uuid() +} + +pub(crate) fn has_local_session() -> bool { + authenticated_user_uuid().is_some() } diff --git a/src-tauri/src/commands/local_api.rs b/src-tauri/src/commands/local_api.rs index d1b3a911..d561a918 100644 --- a/src-tauri/src/commands/local_api.rs +++ b/src-tauri/src/commands/local_api.rs @@ -9,14 +9,20 @@ pub fn get_local_api_runtime_running() -> bool { #[tauri::command] pub async fn start_local_api_runtime() -> Result<()> { let ctx = AppContext::get(); - ctx.local_api_manager.refresh_from_server().await?; + let app = crate::app::handle::get_app_handle()?; + use tauri::Manager; + let business_context = app.state::(); + ctx.local_api_manager.refresh(&business_context).await?; Ok(()) } #[tauri::command] pub async fn reload_local_api_runtime() -> Result<()> { let ctx = AppContext::get(); - ctx.local_api_manager.refresh_from_server().await?; + let app = crate::app::handle::get_app_handle()?; + use tauri::Manager; + let business_context = app.state::(); + ctx.local_api_manager.refresh(&business_context).await?; Ok(()) } diff --git a/src-tauri/src/commands/mihomo.rs b/src-tauri/src/commands/mihomo.rs index 97eddbdd..4252b4ad 100644 --- a/src-tauri/src/commands/mihomo.rs +++ b/src-tauri/src/commands/mihomo.rs @@ -72,10 +72,7 @@ pub async fn get_local_mihomo_proxies( #[tauri::command] pub async fn ensure_mihomo_local_proxy_listeners(app: tauri::AppHandle) -> Result { - AppContext::get() - .mihomo_manager - .ensure_local_proxy_listeners(&app) - .await + AppContext::get().mihomo_manager.ensure_local_proxy_listeners(&app).await } #[tauri::command] diff --git a/src-tauri/src/commands/network.rs b/src-tauri/src/commands/network.rs index cb9a91de..a1d4b616 100644 --- a/src-tauri/src/commands/network.rs +++ b/src-tauri/src/commands/network.rs @@ -1,7 +1,6 @@ /// 网络模块命令 /// /// 命令层仅负责参数解析和响应,业务逻辑由服务层处理 -use crate::app::context::AppContext; use crate::core::error::Result; use crate::infrastructure::http::client::JsonRespnse; use crate::services::connectivity::{DownloadService, ProxyService}; @@ -15,106 +14,33 @@ pub use crate::infrastructure::proxy::{IpInfo, ProxyConfig, ProxyTestResult}; // HTTP 请求命令 // ============================================================================ -/// 文件上传信息 -#[derive(serde::Deserialize)] -pub struct FileInfo { - /// 文件路径 - pub path: String, - /// 文件名(可选,不指定则使用文件路径中的文件名) - #[serde(default)] - pub file_name: Option, - /// MIME 类型(可选,不指定则自动推断) - #[serde(default)] - pub mime_type: Option, -} - -/// HTTP GET 请求 -#[tauri::command] -pub async fn http_get(url: String) -> std::result::Result { - let ctx = AppContext::get(); - ctx.main_server_client.get(&url).await.map_err(|e| e.to_string()) -} - -/// HTTP POST 请求 +/// 将业务请求分发到内嵌数据库。未知路由直接在本地失败,不再回退到远程服务。 #[tauri::command] pub async fn http_post( url: String, data: Option, + business_context: tauri::State<'_, business::svc_ctx::SvcCtx>, ) -> std::result::Result { - let ctx = AppContext::get(); - ctx.main_server_client.post(&url, &data).await.map_err(|e| e.to_string()) -} - -/// HTTP POST 表单请求(用于文件上传) -#[tauri::command] -pub async fn http_post_form( - url: String, - files: Option>>, - fields: Option>, -) -> std::result::Result { - use reqwest::multipart::{Form, Part}; - use tokio::fs::File; - use tokio::io::AsyncReadExt; - - let mut form = Form::new(); - - // 添加文件字段 - if let Some(files_map) = files { - for (field_name, file_list) in files_map { - for file_info in file_list { - let mut file = File::open(&file_info.path).await.map_err(|e| e.to_string())?; - - let mut buffer = Vec::new(); - file.read_to_end(&mut buffer).await.map_err(|e| e.to_string())?; - - // 确定文件名 - let file_name = file_info.file_name.clone().unwrap_or_else(|| { - std::path::Path::new(&file_info.path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("file") - .to_string() - }); - - // 创建 Part - let mut part = Part::bytes(buffer).file_name(file_name); - - // 设置 MIME 类型 - if let Some(ref mime) = file_info.mime_type { - part = part.mime_str(mime).map_err(|e| e.to_string())?; - } - - form = form.part(field_name.clone(), part); - } - } + let payload = data.unwrap_or_else(|| Value::Object(Default::default())); + let request_context = business_context.for_current_user().map_err(|error| error.to_string())?; + if let Some(result) = + business::dispatcher::dispatch_post(&request_context, &url, &payload).await + { + return Ok(match result { + Ok(data) => JsonRespnse { + code: Some(1), + message: Some("OK".to_string()), + data: (!data.is_null()).then_some(data), + }, + Err(message) => JsonRespnse { + code: Some(-1), + message: Some(message), + data: None, + }, + }); } - // 添加文本字段 - if let Some(fields_map) = fields { - for (key, value) in fields_map { - form = form.text(key, value); - } - } - - let ctx = AppContext::get(); - ctx.main_server_client.post_form(&url, form).await.map_err(|e| e.to_string()) -} - -/// HTTP PUT 请求 -#[tauri::command] -pub async fn http_put( - url: String, - data: Option, -) -> std::result::Result { - let ctx = AppContext::get(); - ctx.main_server_client.put(&url, &data).await.map_err(|e| e.to_string()) -} - -/// HTTP DELETE 请求 -#[tauri::command] -pub async fn http_delete(url: String, data: Value) -> std::result::Result { - let ctx = AppContext::get(); - ctx.main_server_client.delete(&url, &data).await.map_err(|e| e.to_string()) + Err(format!("本地业务路由不存在: {url}")) } // ============================================================================ diff --git a/src-tauri/src/commands/rpa.rs b/src-tauri/src/commands/rpa.rs index 1ca9cfb0..4dd56a33 100644 --- a/src-tauri/src/commands/rpa.rs +++ b/src-tauri/src/commands/rpa.rs @@ -64,12 +64,10 @@ pub async fn execute_local_rpa_script( }); } - return Err( - envelope - .error - .filter(|message| !message.trim().is_empty()) - .unwrap_or_else(|| "LOCAL_SCRIPT_EXECUTION_FAILED".to_string()), - ); + return Err(envelope + .error + .filter(|message| !message.trim().is_empty()) + .unwrap_or_else(|| "LOCAL_SCRIPT_EXECUTION_FAILED".to_string())); } if !stderr.is_empty() { diff --git a/src-tauri/src/commands/security.rs b/src-tauri/src/commands/security.rs index 2ab1324f..9b5ed1e4 100644 --- a/src-tauri/src/commands/security.rs +++ b/src-tauri/src/commands/security.rs @@ -1,17 +1,8 @@ -use crate::app::context::AppContext; /// 安全相关的 Tauri 命令 use crate::app::session_lock::{SessionLockManager, SessionLockStateResponse}; use crate::core::error::Result; use tauri::{AppHandle, State}; -/// 获取客户端公钥 -#[tauri::command] -pub fn get_client_public_key() -> Result { - let ctx = AppContext::get(); - let public_key = ctx.rsa_keypair.get_public_key()?; - Ok(public_key) -} - /// 上报用户活跃 #[tauri::command] pub async fn report_user_activity( diff --git a/src-tauri/src/commands/updater.rs b/src-tauri/src/commands/updater.rs deleted file mode 100644 index af9f9c93..00000000 --- a/src-tauri/src/commands/updater.rs +++ /dev/null @@ -1,70 +0,0 @@ -/// 更新相关的 Tauri 命令 -/// -/// 提供检查更新和下载的功能,由主程序调用 -use crate::core::error::Result; -use crate::services::updater::{CheckResult, DownloadResult, PreparedUpdateInfo, UpdateService}; -use tauri::AppHandle; - -/// 简单检查是否有可用更新(仅检查,不缓存计划,不发送事件) -/// -/// 用于设置页面的「检查更新」按钮,返回布尔值。具体更新逻辑由重启时自动完成。 -#[tauri::command] -pub async fn check_update_available() -> Result { - UpdateService::check_update_available().await -} - -/// 检查更新(仅检查,不下载) -/// -/// 检查是否有可用更新,并将更新计划缓存到内存 -/// -/// # 参数 -/// - `app_handle`: Tauri 应用句柄,用于 emit 事件 -/// -/// # 返回 -/// 返回检查结果,包含是否有更新、更新数量以及计划是否可用 -#[tauri::command] -pub async fn check_updates(app_handle: AppHandle) -> Result { - UpdateService::check_updates(app_handle).await -} - -/// 下载更新(执行下载,支持进度) -/// -/// 从内存中的更新计划读取任务,执行下载和校验,实时发送进度事件 -/// -/// # 参数 -/// - `app_handle`: Tauri 应用句柄,用于 emit 事件 -/// # 返回 -/// 返回下载结果,包含任务文件路径和成功数量 -#[tauri::command] -pub async fn download_updates( - app_handle: AppHandle, - plan_file: Option, -) -> Result { - UpdateService::download_updates(app_handle, plan_file).await -} - -/// 启动更新安装并退出主程序 -/// -/// # 参数 -/// - `app_handle`: Tauri 应用句柄 -/// -/// # 说明 -/// 启动 updater.exe install 命令,然后退出主程序 -/// 任务文件路径由统一路径层提供(update_tasks.json) -#[tauri::command] -pub async fn start_update_install(app_handle: AppHandle) -> Result<()> { - UpdateService::start_update_install(app_handle).await -} - -#[tauri::command] -pub async fn start_prepared_update_install( - app_handle: AppHandle, - kind: Option, -) -> Result<()> { - UpdateService::start_prepared_update_install(app_handle, kind).await -} - -#[tauri::command] -pub async fn get_prepared_update() -> Result> { - UpdateService::get_prepared_update().await -} diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 84887028..6c01beb6 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -47,12 +47,7 @@ pub async fn create_syncer_window(app_handle: AppHandle) -> Result<()> { WindowService::create_syncer_window(&app_handle).await } -/// 创建启动窗口(内部使用,不暴露为 Tauri 命令) -pub fn create_splashscreen_window(app_handle: AppHandle) -> Result<()> { - WindowService::create_splashscreen_window(&app_handle) -} - /// 创建主窗口(内部使用,不暴露为 Tauri 命令) -pub async fn create_main_window(app_handle: AppHandle) -> Result<()> { - WindowService::create_main_window(&app_handle).await +pub fn create_main_window(app_handle: AppHandle) -> Result<()> { + WindowService::create_main_window(&app_handle) } diff --git a/src-tauri/src/core/config/encryption/crypto.rs b/src-tauri/src/core/config/encryption/crypto.rs deleted file mode 100644 index f761aadd..00000000 --- a/src-tauri/src/core/config/encryption/crypto.rs +++ /dev/null @@ -1,273 +0,0 @@ -//! 配置加密/解密模块 -//! -//! 提供配置文件的多层加密和解密功能,使用 AES-256-GCM 加密算法。 -//! 包含多层防御:密钥派生、数据变换、分块加密、认证标签。 - -use aes_gcm::{ - Aes256Gcm, Nonce, - aead::{Aead, KeyInit}, -}; -use sha2::{Digest, Sha256}; - -use super::key_derivation::derive_subkeys; - -// 使用简单的 Result 类型,兼容 build.rs 和运行时 -type Result = std::result::Result; - -/// 加密配置数据(多层防御) -/// -/// 加密流程: -/// 1. 数据预变换(XOR + 字节重排) -/// 2. 分块加密(每块使用不同的 nonce) -/// 3. 添加认证标签(防篡改) -/// 4. 数据后变换(字节混淆) -pub fn encrypt(plaintext: &[u8]) -> Result> { - let keys = derive_subkeys(); - - // 第一层:数据预变换(混淆) - let transformed = pre_transform(plaintext, &keys.transform_key); - - // 第二层:分块加密 - let encrypted = block_encrypt(&transformed, &keys.encryption_key)?; - - // 第三层:添加认证标签 - let authenticated = add_auth_tag(&encrypted, &keys.auth_key); - - // 第四层:数据后变换 - let final_data = post_transform(&authenticated, &keys.transform_key); - - Ok(final_data) -} - -/// 解密配置数据(多层防御) -/// -/// 解密流程(加密的逆过程): -/// 1. 数据逆后变换 -/// 2. 验证认证标签 -/// 3. 分块解密 -/// 4. 数据逆预变换 -pub fn decrypt(ciphertext: &[u8]) -> Result> { - let keys = derive_subkeys(); - - // 第一层:数据逆变换 - let untransformed = post_transform_reverse(ciphertext, &keys.transform_key); - - // 第二层:验证认证标签 - let (data, expected_tag) = extract_auth_tag(&untransformed)?; - verify_auth_tag(data, expected_tag, &keys.auth_key)?; - - // 第三层:分块解密 - let decrypted = block_decrypt(data, &keys.encryption_key)?; - - // 第四层:数据逆预变换 - let plaintext = pre_transform_reverse(&decrypted, &keys.transform_key); - - Ok(plaintext) -} - -/// 数据预变换:XOR + 字节重排 -fn pre_transform(data: &[u8], key: &[u8; 16]) -> Vec { - if data.is_empty() { - return Vec::new(); - } - - let mut result = Vec::with_capacity(data.len()); - - // XOR 变换 - for (i, &byte) in data.iter().enumerate() { - result.push(byte ^ key[i % 16]); - } - - result.reverse(); - let shift = transform_shift(result.len(), key); - result.rotate_left(shift); - result -} - -/// 数据预变换的逆操作 -fn pre_transform_reverse(data: &[u8], key: &[u8; 16]) -> Vec { - if data.is_empty() { - return Vec::new(); - } - - let mut unpermuted = data.to_vec(); - let shift = transform_shift(unpermuted.len(), key); - unpermuted.rotate_right(shift); - unpermuted.reverse(); - - // 逆 XOR - let mut result = Vec::with_capacity(unpermuted.len()); - for (i, &byte) in unpermuted.iter().enumerate() { - result.push(byte ^ key[i % 16]); - } - - result -} - -/// 分块加密:将数据分成多块,每块使用不同的 nonce -fn block_encrypt(data: &[u8], key: &[u8; 32]) -> Result> { - const BLOCK_SIZE: usize = 4096; // 4KB 块 - - let cipher = Aes256Gcm::new(key.into()); - let mut result = Vec::new(); - - // 写入块数量 - let block_count = (data.len() + BLOCK_SIZE - 1) / BLOCK_SIZE; - result.extend_from_slice(&(block_count as u32).to_le_bytes()); - - // 分块加密 - for (block_idx, chunk) in data.chunks(BLOCK_SIZE).enumerate() { - // 为每个块派生不同的 nonce - let nonce_bytes = derive_block_nonce(block_idx, key); - let nonce = Nonce::from_slice(&nonce_bytes); - - // 加密块 - let encrypted_block = - cipher.encrypt(nonce, chunk).map_err(|e| format!("Encryption failed: {}", e))?; - - // 写入块大小和数据 - result.extend_from_slice(&(encrypted_block.len() as u32).to_le_bytes()); - result.extend_from_slice(&encrypted_block); - } - - Ok(result) -} - -/// 分块解密 -fn block_decrypt(data: &[u8], key: &[u8; 32]) -> Result> { - let cipher = Aes256Gcm::new(key.into()); - let mut result = Vec::new(); - - // 读取块数量 - if data.len() < 4 { - return Err("Invalid data format".to_string()); - } - let block_count = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize; - - let mut offset = 4; - for block_idx in 0..block_count { - // 读取块大小 - if offset + 4 > data.len() { - return Err("Invalid block format".to_string()); - } - let block_size = u32::from_le_bytes([ - data[offset], - data[offset + 1], - data[offset + 2], - data[offset + 3], - ]) as usize; - offset += 4; - - // 读取块数据 - if offset + block_size > data.len() { - return Err("Invalid block data".to_string()); - } - let block_data = &data[offset..offset + block_size]; - offset += block_size; - - // 派生 nonce 并解密 - let nonce_bytes = derive_block_nonce(block_idx, key); - let nonce = Nonce::from_slice(&nonce_bytes); - - let decrypted_block = cipher - .decrypt(nonce, block_data) - .map_err(|e| format!("Decryption failed: {}", e))?; - - result.extend_from_slice(&decrypted_block); - } - - Ok(result) -} - -/// 为每个块派生不同的 nonce -fn derive_block_nonce(block_idx: usize, key: &[u8; 32]) -> [u8; 12] { - let mut hasher = Sha256::new(); - hasher.update(key); - hasher.update(b"block-nonce"); - hasher.update(&(block_idx as u64).to_le_bytes()); - let result = hasher.finalize(); - - let mut nonce = [0u8; 12]; - nonce.copy_from_slice(&result[..12]); - nonce -} - -/// 添加认证标签 -fn add_auth_tag(data: &[u8], auth_key: &[u8; 32]) -> Vec { - let mut hasher = Sha256::new(); - hasher.update(auth_key); - hasher.update(data); - let tag = hasher.finalize(); - - let mut result = Vec::with_capacity(data.len() + 32); - result.extend_from_slice(data); - result.extend_from_slice(&tag); - result -} - -/// 提取认证标签 -fn extract_auth_tag(data: &[u8]) -> Result<(&[u8], &[u8])> { - if data.len() < 32 { - return Err("Data too short for auth tag".to_string()); - } - let split_pos = data.len() - 32; - Ok((&data[..split_pos], &data[split_pos..])) -} - -/// 验证认证标签 -fn verify_auth_tag(data: &[u8], expected_tag: &[u8], auth_key: &[u8; 32]) -> Result<()> { - let mut hasher = Sha256::new(); - hasher.update(auth_key); - hasher.update(data); - let computed_tag = hasher.finalize(); - - if computed_tag.as_slice() != expected_tag { - return Err("Authentication failed".to_string()); - } - - Ok(()) -} - -/// 数据后变换:简单的字节混淆 -fn post_transform(data: &[u8], key: &[u8; 16]) -> Vec { - data.iter().enumerate().map(|(i, &b)| b.wrapping_add(key[i % 16])).collect() -} - -/// 数据后变换的逆操作 -fn post_transform_reverse(data: &[u8], key: &[u8; 16]) -> Vec { - data.iter().enumerate().map(|(i, &b)| b.wrapping_sub(key[i % 16])).collect() -} - -fn transform_shift(len: usize, key: &[u8; 16]) -> usize { - if len <= 1 { - return 0; - } - - let seed = key.iter().take(4).fold(0usize, |acc, byte| (acc << 8) | (*byte as usize)); - (seed.wrapping_add(len).wrapping_add(13)) % len -} - -#[cfg(test)] -mod tests { - use super::{decrypt, encrypt}; - - #[test] - fn roundtrip_preserves_all_lengths_up_to_1024() { - for len in 0..=1024usize { - let input = (0..len).map(|index| ((index * 31 + 17) % 256) as u8).collect::>(); - let encrypted = encrypt(&input).expect("encrypt should succeed"); - let decrypted = decrypt(&encrypted).expect("decrypt should succeed"); - assert_eq!(decrypted, input, "roundtrip mismatch for len={len}"); - } - } - - #[test] - fn roundtrip_preserves_lengths_that_are_multiples_of_seven() { - for len in [7usize, 14, 497, 504, 4095, 4096, 4097] { - let input = (0..len).map(|index| ((index * 13 + 29) % 256) as u8).collect::>(); - let encrypted = encrypt(&input).expect("encrypt should succeed"); - let decrypted = decrypt(&encrypted).expect("decrypt should succeed"); - assert_eq!(decrypted, input, "roundtrip mismatch for len={len}"); - } - } -} diff --git a/src-tauri/src/core/config/encryption/key_derivation.rs b/src-tauri/src/core/config/encryption/key_derivation.rs deleted file mode 100644 index eee9a8f6..00000000 --- a/src-tauri/src/core/config/encryption/key_derivation.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! 密钥派生模块 -//! -//! 通过复杂算法派生固定的加密密钥,避免密钥以明文形式出现在代码中。 -//! 此模块在编译时(build.rs)和运行时都会被使用,必须返回相同的结果。 - -use sha2::{Digest, Sha256}; - -/// 通过复杂算法派生主密钥 -/// -/// 使用多个分散的种子常量和复杂的计算过程,避免密钥以明文形式出现在二进制中。 -/// 注意:此函数在编译时和运行时都会被调用,必须返回相同的结果。 -fn derive_master_key() -> [u8; 32] { - // 第一层:分散的种子常量(伪装成配置参数) - const SEED_ALPHA: u64 = 0x1A2B3C4D5E6F7A8B; - const SEED_BETA: u64 = 0xFEDCBA9876543210; - const SEED_GAMMA: &[u8] = &[ - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x69, 0x6f, - 0x6e, 0x53, 0x61, 0x6c, 0x74, 0x32, 0x30, 0x32, 0x34, - ]; - const SEED_DELTA: u32 = 0x89ABCDEF; - const SEED_EPSILON: u16 = 0x1234; - - // 第二层:构建初始缓冲区(使用不同的字节序和位运算) - let mut buffer = Vec::with_capacity(128); - buffer.extend_from_slice(&SEED_ALPHA.to_le_bytes()); - buffer.extend_from_slice(&SEED_BETA.to_be_bytes()); - buffer.extend_from_slice(SEED_GAMMA); - buffer.extend_from_slice(&SEED_DELTA.rotate_left(13).to_le_bytes()); - buffer.extend_from_slice(&SEED_EPSILON.wrapping_mul(7).to_be_bytes()); - - // 第三层:初始哈希 - let mut hasher = Sha256::new(); - hasher.update(&buffer); - let mut result = hasher.finalize(); - - // 第四层:多轮迭代(1000轮,增加计算复杂度) - for round in 0..1000 { - let mut hasher = Sha256::new(); - hasher.update(&result); - // 每轮混入轮数,确保每轮结果不同 - hasher.update(&(round as u32).to_le_bytes()); - result = hasher.finalize(); - } - - // 第五层:最终混淆(使用固定的应用标识) - const APP_IDENTIFIER: &[u8] = &[ - 0x53, 0x69, 0x6d, 0x50, 0x72, 0x69, 0x6e, 0x74, 0x2d, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x2d, 0x76, 0x31, - ]; - let mut hasher = Sha256::new(); - hasher.update(&result); - hasher.update(APP_IDENTIFIER); - result = hasher.finalize(); - - result.into() -} - -/// 密钥派生结果 -/// -/// 包含用于不同目的的子密钥 -pub struct DerivedKeys { - pub encryption_key: [u8; 32], // 用于实际加密 - pub auth_key: [u8; 32], // 用于认证/校验 - pub transform_key: [u8; 16], // 用于数据变换 -} - -/// 从主密钥派生子密钥 -/// -/// 使用不同的派生路径,生成多个独立的子密钥 -pub fn derive_subkeys() -> DerivedKeys { - let master = derive_master_key(); - - // 派生加密密钥 - let encryption_key = derive_subkey( - &master, - &[0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x69, 0x6f, 0x6e], - 1000, - ); - - // 派生认证密钥 - let auth_key = derive_subkey( - &master, - &[ - 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, - ], - 1500, - ); - - // 派生变换密钥 - let transform_key_full = derive_subkey( - &master, - &[0x74, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x6f, 0x72, 0x6d], - 2000, - ); - let mut transform_key = [0u8; 16]; - transform_key.copy_from_slice(&transform_key_full[..16]); - - DerivedKeys { - encryption_key, - auth_key, - transform_key, - } -} - -/// 子密钥派生函数 -/// -/// 使用 HKDF 类似的方法从主密钥派生子密钥 -fn derive_subkey(master: &[u8; 32], context: &[u8], rounds: usize) -> [u8; 32] { - let mut hasher = Sha256::new(); - hasher.update(master); - hasher.update(context); - let mut result = hasher.finalize(); - - // 多轮迭代 - for i in 0..rounds { - let mut hasher = Sha256::new(); - hasher.update(&result); - hasher.update(&(i as u32).to_le_bytes()); - hasher.update(context); - result = hasher.finalize(); - } - - result.into() -} diff --git a/src-tauri/src/core/config/encryption/mod.rs b/src-tauri/src/core/config/encryption/mod.rs deleted file mode 100644 index 9e2f0746..00000000 --- a/src-tauri/src/core/config/encryption/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! 配置加密模块 -//! -//! 提供配置文件的加密和解密功能,包括密钥派生和多层加密 - -mod crypto; -mod key_derivation; - -// 导出加密/解密函数 -pub use crypto::{decrypt, encrypt}; diff --git a/src-tauri/src/core/config/loader.rs b/src-tauri/src/core/config/loader.rs deleted file mode 100644 index 7d1a77bf..00000000 --- a/src-tauri/src/core/config/loader.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! 配置加载和解密 -//! -//! 负责从不同来源加载配置并进行解密 - -use super::encryption; -use super::types::AppConfig; -use crate::core::error::{Error, Result}; -use config::{Config, FileFormat}; - -/// 编译期从 OUT_DIR 中引入加密后的配置二进制 -const ENCRYPTED_CONFIG: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/config_encrypted.bin")); - -/// 解密配置内容 -fn decrypt_config() -> Result { - let decrypted = - encryption::decrypt(ENCRYPTED_CONFIG).map_err(|e| Error::ConfigDecryptFailed(e))?; - String::from_utf8(decrypted).map_err(|e| Error::ConfigDecryptFailed(e.to_string())) -} - -/// 从字符串加载配置 -pub fn load_from_str(config_str: &str) -> Result { - let config = Config::builder() - .add_source(config::File::from_str(config_str, FileFormat::Toml)) - .add_source(config::File::with_name(".").required(false)) - .add_source(config::Environment::with_prefix("APP")) - .build() - .map_err(|e| Error::ConfigLoadFailed(e.to_string()))?; - - config.try_deserialize().map_err(|e| Error::ConfigParseFailed(e.to_string())) -} - -/// 从文件路径加载配置 -pub fn load_from_path(config_path: &str) -> Result { - let config = Config::builder() - .add_source(config::File::with_name(config_path)) - .add_source(config::File::with_name(".").required(false)) - .add_source(config::Environment::with_prefix("APP")) - .build() - .map_err(|e| Error::ConfigLoadFailed(e.to_string()))?; - - config.try_deserialize().map_err(|e| Error::ConfigParseFailed(e.to_string())) -} - -/// 加载嵌入的加密配置 -pub fn load_embedded() -> Result { - let config_str = decrypt_config()?; - load_from_str(&config_str) -} diff --git a/src-tauri/src/core/config/mod.rs b/src-tauri/src/core/config/mod.rs deleted file mode 100644 index 386de43f..00000000 --- a/src-tauri/src/core/config/mod.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! 配置管理模块 -//! -//! 提供配置的加载、验证和全局访问 - -mod encryption; -mod loader; -mod types; -mod validator; - -pub use types::{AppConfig, ServerConfig, UpdaterConfig}; - -use crate::core::error::{Error, Result}; -use std::{fs, path::PathBuf, sync::OnceLock}; - -/// 全局配置实例 -static CONFIG: OnceLock = OnceLock::new(); - -/// 初始化配置(从嵌入的加密配置) -/// -/// 这是最常用的初始化方式,配置在编译时被加密并嵌入到二进制中 -pub fn init() -> Result<()> { - let config = if let Some(config_name) = local_config_file_name() { - let config_path = resolve_local_config_path(config_name); - let config_str = fs::read_to_string(&config_path) - .map_err(|e| Error::ConfigLoadFailed(format!("{}: {}", config_path.display(), e)))?; - loader::load_from_str(&config_str)? - } else { - loader::load_embedded()? - }; - validator::validate(&config)?; - - CONFIG.set(config).map_err(|_| Error::ConfigAlreadyInitialized)?; - Ok(()) -} - -/// 初始化配置(从字符串) -/// -/// 用于测试或从其他来源加载配置 -pub fn init_from_str(config_str: &str) -> Result<()> { - let config = loader::load_from_str(config_str)?; - validator::validate(&config)?; - - CONFIG.set(config).map_err(|_| Error::ConfigAlreadyInitialized)?; - Ok(()) -} - -/// 初始化配置(从文件路径) -/// -/// 用于开发环境或特殊场景 -pub fn init_from_path(config_path: &str) -> Result<()> { - let config = loader::load_from_path(config_path)?; - validator::validate(&config)?; - - CONFIG.set(config).map_err(|_| Error::ConfigAlreadyInitialized)?; - Ok(()) -} - -/// 获取配置(返回 Option) -/// -/// 如果配置未初始化,返回 None -pub fn get() -> Option<&'static AppConfig> { - CONFIG.get() -} - -/// 获取配置(必须已初始化) -/// -/// 如果配置未初始化,返回错误 -pub fn get_or_err() -> Result<&'static AppConfig> { - CONFIG.get().ok_or(Error::ConfigNotInitialized) -} - -/// 获取配置(必须已初始化,否则 panic) -/// -/// 仅在确定配置已初始化的场景使用 -pub fn get_or_panic() -> &'static AppConfig { - CONFIG.get().expect("Config not initialized. Call config::init() first.") -} - -fn local_config_file_name() -> Option<&'static str> { - if cfg!(feature = "test") { - Some("config.test.toml") - } else if cfg!(feature = "development") { - Some("config.development.toml") - } else { - None - } -} - -fn resolve_local_config_path(config_name: &str) -> PathBuf { - let cwd_path = PathBuf::from(config_name); - if cwd_path.exists() { - return cwd_path; - } - - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(config_name) -} diff --git a/src-tauri/src/core/config/types.rs b/src-tauri/src/core/config/types.rs deleted file mode 100644 index ba838ff5..00000000 --- a/src-tauri/src/core/config/types.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! 配置类型定义 -//! -//! 定义了应用的所有配置结构体 - -use serde::Deserialize; - -/// 服务器配置 -#[derive(Deserialize, Debug, Clone)] -pub struct ServerConfig { - /// 服务器基础URL - pub base_url: String, - /// API版本 - pub version: String, - /// 密钥 - pub secret_key: String, -} - -/// 更新器配置 -#[derive(Deserialize, Debug, Clone)] -pub struct UpdaterConfig { - pub check_url: String, - pub latest_json_url: String, - - /// 下载的临时目录(可选)。 - /// - 若为空:默认使用统一根目录下的 `updates` - /// - 若填写:使用该目录(相对路径基于统一根目录) - #[serde(default)] - pub updater_temp_dir: Option, -} - -/// 应用配置 -#[derive(Deserialize, Debug, Clone)] -pub struct AppConfig { - /// 服务器配置 - pub server: ServerConfig, - /// 更新器配置 - pub updater: UpdaterConfig, -} diff --git a/src-tauri/src/core/config/validator.rs b/src-tauri/src/core/config/validator.rs deleted file mode 100644 index 3bcf05d8..00000000 --- a/src-tauri/src/core/config/validator.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! 配置验证 -//! -//! 验证配置的有效性 - -use super::types::AppConfig; -use crate::core::error::{Error, Result}; - -/// 验证配置 -pub fn validate(config: &AppConfig) -> Result<()> { - validate_server_config(config)?; - validate_updater_config(config)?; - Ok(()) -} - -/// 验证服务器配置 -fn validate_server_config(config: &AppConfig) -> Result<()> { - if config.server.base_url.is_empty() { - return Err(Error::ConfigValidationFailed( - "server.base_url cannot be empty".to_string(), - )); - } - - if !config.server.base_url.starts_with("http://") - && !config.server.base_url.starts_with("https://") - { - return Err(Error::ConfigValidationFailed( - "server.base_url must start with http:// or https://".to_string(), - )); - } - - if config.server.version.is_empty() { - return Err(Error::ConfigValidationFailed( - "server.version cannot be empty".to_string(), - )); - } - - if config.server.secret_key.is_empty() { - return Err(Error::ConfigValidationFailed( - "server.secret_key cannot be empty".to_string(), - )); - } - - Ok(()) -} - -/// 验证更新器配置 -fn validate_updater_config(config: &AppConfig) -> Result<()> { - if config.updater.check_url.is_empty() { - return Err(Error::ConfigValidationFailed( - "updater.check_url cannot be empty".to_string(), - )); - } - - if !config.updater.check_url.starts_with("http://") - && !config.updater.check_url.starts_with("https://") - { - return Err(Error::ConfigValidationFailed( - "updater.check_url must start with http:// or https://".to_string(), - )); - } - - if config.updater.latest_json_url.is_empty() { - return Err(Error::ConfigValidationFailed( - "updater.latest_json_url cannot be empty".to_string(), - )); - } - - if !config.updater.latest_json_url.starts_with("http://") - && !config.updater.latest_json_url.starts_with("https://") - { - return Err(Error::ConfigValidationFailed( - "updater.latest_json_url must start with http:// or https://".to_string(), - )); - } - - Ok(()) -} diff --git a/src-tauri/src/core/error/convert.rs b/src-tauri/src/core/error/convert.rs index ef524563..8a4d3a44 100644 --- a/src-tauri/src/core/error/convert.rs +++ b/src-tauri/src/core/error/convert.rs @@ -4,25 +4,6 @@ use super::Error; -/// 从基础设施层的网络错误转换 -impl From for Error { - fn from(err: crate::infrastructure::main_server::error::ResponseError) -> Self { - use crate::infrastructure::main_server::error::ResponseError; - match err { - ResponseError::Unauthorized { .. } => Self::AuthTokenInvalid, - ResponseError::PublicKeyExpired => Self::PublicKeyParseFailed, - ResponseError::BadRequest { .. } => Self::NetworkRequestFailed, - ResponseError::UnprocessableEntity { .. } => Self::NetworkRequestFailed, - ResponseError::EmptyResponse { .. } => Self::NetworkRequestFailed, - ResponseError::JsonParseError { .. } => Self::DeserializeFailed, - ResponseError::ReadResponseFailed(_) => Self::NetworkRequestFailed, - ResponseError::ParseEncryptedDataFailed(_) => Self::DecryptFailed, - ResponseError::DecryptFailed(_) => Self::DecryptFailed, - ResponseError::ParseResponseFailed(_) => Self::DeserializeFailed, - } - } -} - /// 从 anyhow::Error 转换(兜底转换) impl From for Error { fn from(_err: anyhow::Error) -> Self { diff --git a/src-tauri/src/core/error/types.rs b/src-tauri/src/core/error/types.rs index 72281476..66d72d35 100644 --- a/src-tauri/src/core/error/types.rs +++ b/src-tauri/src/core/error/types.rs @@ -531,10 +531,6 @@ pub enum Error { #[error("[220401] UI component initialization failed")] UiComponentInitFailed, - /// 220501: Splashscreen operation failed - #[error("[220501] Splashscreen operation failed")] - SplashscreenOperationFailed, - // ==================== Path Management (23) ==================== /// 230101: Path resolution failed #[error("[230101] Path resolution failed")] diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 145b8eec..a2818cfd 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -1,7 +1,6 @@ // Core 层:核心业务逻辑和领域模型 pub mod app_info; -pub mod config; pub mod error; pub mod logger; pub mod paths; diff --git a/src-tauri/src/core/paths.rs b/src-tauri/src/core/paths.rs index bf646b58..f0f5956d 100644 --- a/src-tauri/src/core/paths.rs +++ b/src-tauri/src/core/paths.rs @@ -29,9 +29,6 @@ struct BootstrapConfig { cache_dir: Option, data_dir: Option, kernels_dir: Option, - referral_dir: Option, - updater_dir: Option, - update_tasks_file: Option, } /// 路径管理器 @@ -129,6 +126,13 @@ impl PathManager { Ok(dir) } + /// SQLite database containing the local-first business data. + pub fn get_business_database_file() -> Result { + let path = Self::get_data_dir()?.join("simprint.db"); + Self::ensure_parent_dir(&path)?; + Ok(path) + } + pub fn get_local_dir(app: &tauri::AppHandle) -> Result { let dir = Self::get_app_data_dir(app)?.join(".local"); Self::ensure_dir(&dir)?; @@ -171,38 +175,6 @@ impl PathManager { Ok(dir) } - pub fn get_referral_dir() -> Result { - let dir = Self::resolve_named_dir( - Self::load_bootstrap().referral_dir.as_deref(), - Self::get_root_dir()?, - "referral", - )?; - Self::ensure_dir(&dir)?; - Ok(dir) - } - - pub fn get_updater_dir() -> Result { - let dir = Self::resolve_named_dir( - Self::load_bootstrap().updater_dir.as_deref(), - Self::get_root_dir()?, - "updates", - )?; - Self::ensure_dir(&dir)?; - Ok(dir) - } - - pub fn get_update_tasks_file() -> Result { - let root = Self::get_root_dir()?; - let path = Self::load_bootstrap() - .update_tasks_file - .as_deref() - .map(|value| Self::resolve_override_path(value, &root)) - .transpose()? - .unwrap_or_else(|| root.join("update_tasks.json")); - Self::ensure_parent_dir(&path)?; - Ok(path) - } - pub fn get_store_file() -> Result { let path = Self::get_config_dir()?.join("store.json"); Self::ensure_parent_dir(&path)?; @@ -335,21 +307,6 @@ impl PathManager { &Self::get_kernels_dir()?.to_string_lossy().to_string(), ) .context("写入注册表 KernelsDir 失败")?; - key.set_value( - "ReferralDir", - &Self::get_referral_dir()?.to_string_lossy().to_string(), - ) - .context("写入注册表 ReferralDir 失败")?; - key.set_value( - "UpdaterDir", - &Self::get_updater_dir()?.to_string_lossy().to_string(), - ) - .context("写入注册表 UpdaterDir 失败")?; - key.set_value( - "UpdateTasksFile", - &Self::get_update_tasks_file()?.to_string_lossy().to_string(), - ) - .context("写入注册表 UpdateTasksFile 失败")?; } #[cfg(not(target_os = "windows"))] @@ -400,8 +357,5 @@ impl BootstrapConfig { && self.cache_dir.is_none() && self.data_dir.is_none() && self.kernels_dir.is_none() - && self.referral_dir.is_none() - && self.updater_dir.is_none() - && self.update_tasks_file.is_none() } } diff --git a/src-tauri/src/core/utils/hash.rs b/src-tauri/src/core/utils/hash.rs new file mode 100644 index 00000000..0584dd63 --- /dev/null +++ b/src-tauri/src/core/utils/hash.rs @@ -0,0 +1,25 @@ +use anyhow::{Context, Result}; +use sha2::{Digest, Sha256}; +use std::fs::File; +use std::io::Read; +use std::path::Path; + +/// Calculates the complete SHA-256 digest of a file. +pub fn calculate_file_hash(file_path: &Path) -> Result { + let mut file = + File::open(file_path).with_context(|| format!("无法打开文件: {}", file_path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 8192]; + + loop { + let bytes_read = file + .read(&mut buffer) + .with_context(|| format!("读取文件失败: {}", file_path.display()))?; + if bytes_read == 0 { + break; + } + hasher.update(&buffer[..bytes_read]); + } + + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/src-tauri/src/core/utils/mod.rs b/src-tauri/src/core/utils/mod.rs index 80fe812c..c500c463 100644 --- a/src-tauri/src/core/utils/mod.rs +++ b/src-tauri/src/core/utils/mod.rs @@ -1 +1,2 @@ +pub mod hash; pub mod process; diff --git a/src-tauri/src/core/utils/process.rs b/src-tauri/src/core/utils/process.rs index 043a0831..c26a36fa 100644 --- a/src-tauri/src/core/utils/process.rs +++ b/src-tauri/src/core/utils/process.rs @@ -17,10 +17,7 @@ pub struct FindProcessResult { fn normalize_process_identity(value: &str) -> String { let normalized = value.trim().to_ascii_lowercase(); - normalized - .strip_suffix(".exe") - .unwrap_or(&normalized) - .to_string() + normalized.strip_suffix(".exe").unwrap_or(&normalized).to_string() } fn process_matches_candidate( diff --git a/src-tauri/src/domain/credential.rs b/src-tauri/src/domain/credential.rs deleted file mode 100644 index acb59bd1..00000000 --- a/src-tauri/src/domain/credential.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! 凭证领域模型 -//! -//! 封装凭证相关的业务规则和验证逻辑 - -use serde::{Deserialize, Serialize}; - -/// 凭证领域对象 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Credential { - /// 访问令牌 - pub access_token: String, - /// 刷新令牌 - pub refresh_token: String, -} - -impl Credential { - /// 创建新凭证 - pub fn new(access_token: String, refresh_token: String) -> Self { - Self { - access_token, - refresh_token, - } - } - - /// 验证凭证是否有效 - pub fn is_valid(&self) -> bool { - !self.access_token.is_empty() && !self.refresh_token.is_empty() - } - - /// 获取访问令牌 - pub fn access_token(&self) -> &str { - &self.access_token - } - - /// 获取刷新令牌 - pub fn refresh_token(&self) -> &str { - &self.refresh_token - } -} - -/// 记住的凭证(用于自动登录) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RememberedCredential { - /// 邮箱 - pub email: String, - /// 刷新令牌 - pub refresh_token: String, -} - -impl RememberedCredential { - /// 创建记住的凭证 - pub fn new(email: String, refresh_token: String) -> Self { - Self { - email, - refresh_token, - } - } - - /// 验证是否有效 - pub fn is_valid(&self) -> bool { - !self.email.is_empty() && !self.refresh_token.is_empty() - } -} diff --git a/src-tauri/src/domain/environment.rs b/src-tauri/src/domain/environment.rs index 02e411e8..b1980991 100644 --- a/src-tauri/src/domain/environment.rs +++ b/src-tauri/src/domain/environment.rs @@ -72,6 +72,9 @@ pub struct KernelDetail { /// 签名哈希(用于核心文件验证) #[serde(default)] pub signature: Option, + /// 允许复用的历史安装签名;仅用于校验已经存在的内核 + #[serde(default)] + pub compatible_signatures: Vec, /// 是否需要解压 pub requires_extract: bool, } @@ -83,6 +86,7 @@ impl KernelDetail { url, hash, signature: None, + compatible_signatures: Vec::new(), requires_extract, } } diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs index 53aab9fd..e1f2729b 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/domain/mod.rs @@ -2,7 +2,5 @@ //! //! 包含核心业务模型和业务规则,无外部依赖 -pub mod credential; pub mod environment; -pub mod referral_code; pub mod user; diff --git a/src-tauri/src/domain/referral_code.rs b/src-tauri/src/domain/referral_code.rs deleted file mode 100644 index 38ad6fa8..00000000 --- a/src-tauri/src/domain/referral_code.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! 邀请码值对象 -//! -//! 封装邀请码的解码和验证逻辑 - -use crate::core::error::Result; -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; - -/// 邀请码值对象 -#[derive(Debug, Clone)] -pub struct ReferralCode { - code: String, -} - -impl ReferralCode { - /// 创建邀请码 - pub fn new(code: String) -> Self { - Self { code } - } - - /// 从 base64url 编码的字符串解码 - pub fn decode(encoded: &str) -> Result { - let s = encoded.trim(); - if s.is_empty() { - return Err("邀请码为空".into()); - } - - let decoded = URL_SAFE_NO_PAD.decode(s.as_bytes()).map_err(|_| "邀请码解码失败")?; - - let code = String::from_utf8(decoded).map_err(|_| "邀请码格式无效")?; - - if code.is_empty() { - return Err("邀请码为空".into()); - } - - Ok(Self { code }) - } - - /// 验证邀请码是否有效 - pub fn is_valid(&self) -> bool { - !self.code.is_empty() - } - - /// 获取邀请码 - pub fn code(&self) -> &str { - &self.code - } -} diff --git a/src-tauri/src/infrastructure/deeplink/mod.rs b/src-tauri/src/infrastructure/deeplink/mod.rs deleted file mode 100644 index f80aaff1..00000000 --- a/src-tauri/src/infrastructure/deeplink/mod.rs +++ /dev/null @@ -1,31 +0,0 @@ -use reqwest::Url; - -pub mod referral; -pub mod storage; - -/// Deeplink 统一入口:传入 argv 中的某个参数,若是 URL 则分发给各 handler。 -pub fn process_arg(arg: &str) { - if !arg.contains("://") { - return; - } - - let Ok(url) = Url::parse(arg) else { - return; - }; - - // 目前只处理推广码,后续可继续追加更多 handler - referral::handle_referral_code(&url); -} - -/// 冷启动/单实例启动场景:从 argv 中找到第一个 URL 参数并处理。 -pub fn process_first_url_arg(args: I) -where - I: IntoIterator, -{ - for arg in args { - if arg.contains("://") { - process_arg(&arg); - break; - } - } -} diff --git a/src-tauri/src/infrastructure/deeplink/referral.rs b/src-tauri/src/infrastructure/deeplink/referral.rs deleted file mode 100644 index 48cad219..00000000 --- a/src-tauri/src/infrastructure/deeplink/referral.rs +++ /dev/null @@ -1,33 +0,0 @@ -use reqwest::Url; - -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; - -use super::storage; - -/// 从 deep link URL 中提取 `referral_code` 并落盘。 -pub fn handle_referral_code(url: &Url) { - let referral_code = url - .query_pairs() - .find_map(|(k, v)| (k == "referral_code").then(|| v.to_string())); - - if let Some(code) = referral_code.and_then(sanitize_referral_code) { - // 与注册侧提取规则对齐:创建名为 R_{base64url(code)}_R 的空文件 - let encoded = URL_SAFE_NO_PAD.encode(code.as_bytes()); - let marker_name = format!("R_{}_R", encoded); - let _ = storage::store_referral_code(&marker_name); - } -} - -fn sanitize_referral_code(raw: String) -> Option { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return None; - } - // 文件名安全:仅保留字母数字、_、- - let cleaned: String = trimmed - .chars() - .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') - .collect(); - (!cleaned.is_empty()).then_some(cleaned) -} diff --git a/src-tauri/src/infrastructure/deeplink/storage.rs b/src-tauri/src/infrastructure/deeplink/storage.rs deleted file mode 100644 index b02313ea..00000000 --- a/src-tauri/src/infrastructure/deeplink/storage.rs +++ /dev/null @@ -1,15 +0,0 @@ -use std::fs; - -/// 推广码存储:在统一根目录的 `referral/` 创建空文件。 -pub fn store_referral_code(code: &str) -> Result<(), String> { - let referral_dir = - crate::core::paths::PathManager::get_referral_dir().map_err(|e| e.to_string())?; - - // 先删除旧目录,再写入(确保单一来源,避免残留) - let _ = fs::remove_dir_all(&referral_dir); - fs::create_dir_all(&referral_dir).map_err(|e| e.to_string())?; - - let file_path = referral_dir.join(code); - fs::write(&file_path, "").map_err(|e| e.to_string())?; - Ok(()) -} diff --git a/src-tauri/src/infrastructure/http/client/client.rs b/src-tauri/src/infrastructure/http/client/client.rs deleted file mode 100644 index e1cc9ab9..00000000 --- a/src-tauri/src/infrastructure/http/client/client.rs +++ /dev/null @@ -1,156 +0,0 @@ -use reqwest::{Url, header}; -use std::{fmt::Debug, future::Future, pin::Pin, time::Duration}; - -use crate::app::context::AppContext; - -pub type BeforeCallFunction = fn( - rb: reqwest::RequestBuilder, -) -> Pin< - Box> + Send>, ->; - -pub type AfterCallFunction = fn( - response: reqwest::Response, -) -> Pin< - Box> + Send>, ->; - -pub struct Client { - client: reqwest::Client, - before: Vec, - after: Vec, -} - -impl Client { - pub fn new(timeout: u64) -> Client { - Client { - client: reqwest::Client::builder() - .timeout(Duration::from_secs(timeout)) - .default_headers({ - let headers = header::HeaderMap::new(); - headers - }) - .build() - .unwrap(), - before: vec![], - after: vec![], - } - } - - pub fn build_url(resource: &str) -> Result { - let ctx = AppContext::get(); - let config = ctx.config(); - let server_url = config.server.base_url.as_str(); - let version = config.server.version.as_str(); - - let t = Url::parse(server_url)?.join(&format!("{}/", version))?.join(resource)?; - - Ok(t) - } - - async fn run_before( - &self, - mut request_builder: reqwest::RequestBuilder, - ) -> core::result::Result { - for call in &self.before { - request_builder = call(request_builder).await?; - } - Ok(request_builder) - } - - async fn run_after( - &self, - mut response: reqwest::Response, - ) -> core::result::Result { - for call in &self.after { - response = call(response).await?; - } - Ok(response) - } - - pub async fn post( - &self, - url: reqwest::Url, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize + Debug, - { - let request_builder = self.client.post(url).json(json); - let request_builder = self.run_before(request_builder).await?; - let response = request_builder.send().await?; - self.run_after(response).await - } - - pub async fn post_with_headers( - &self, - url: reqwest::Url, - json: &T, - headers: reqwest::header::HeaderMap, - ) -> core::result::Result - where - T: serde::Serialize + Debug, - { - let request_builder = self.client.post(url).headers(headers).json(json); - let request_builder = self.run_before(request_builder).await?; - let response = request_builder.send().await?; - self.run_after(response).await - } - - pub async fn post_form( - &self, - url: reqwest::Url, - form: reqwest::multipart::Form, - ) -> core::result::Result { - let request_builder = self.client.post(url).multipart(form); - let request_builder = self.run_before(request_builder).await?; - let response = request_builder.send().await?; - self.run_after(response).await - } - - pub async fn get( - &self, - url: reqwest::Url, - ) -> core::result::Result { - let request_builder = self.client.get(url); - let request_builder = self.run_before(request_builder).await?; - let response = request_builder.send().await?; - self.run_after(response).await - } - - pub async fn put( - &self, - url: reqwest::Url, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize, - { - let request_builder = self.client.put(url).json(json); - let request_builder = self.run_before(request_builder).await?; - let response = request_builder.send().await?; - self.run_after(response).await - } - - pub async fn delete( - &self, - url: reqwest::Url, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize, - { - let request_builder = self.client.delete(url).json(json); - let request_builder = self.run_before(request_builder).await?; - let response = request_builder.send().await?; - self.run_after(response).await - } - - pub fn before(&mut self, call: BeforeCallFunction) { - self.before.push(call); - } - - pub fn after(&mut self, call: AfterCallFunction) { - self.after.push(call); - } -} diff --git a/src-tauri/src/infrastructure/http/client/mod.rs b/src-tauri/src/infrastructure/http/client/mod.rs index d997053c..ca774ee6 100644 --- a/src-tauri/src/infrastructure/http/client/mod.rs +++ b/src-tauri/src/infrastructure/http/client/mod.rs @@ -1,7 +1,2 @@ -// ============ 基础模块 ============ -mod client; mod types; - -// 重导出基础类型和客户端 -pub use client::{AfterCallFunction, BeforeCallFunction, Client}; pub use types::JsonRespnse; diff --git a/src-tauri/src/infrastructure/http/encryption/mod.rs b/src-tauri/src/infrastructure/http/encryption/mod.rs deleted file mode 100644 index b7089131..00000000 --- a/src-tauri/src/infrastructure/http/encryption/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -/// 加密安全模块 -/// -/// 提供 AES 和 RSA 加密功能 -pub mod aes; -pub mod rsa; - -// 重导出常用类型和函数 -pub use aes::AesSecret; -pub use rsa::RsaSecret; diff --git a/src-tauri/src/infrastructure/http/encryption/rsa.rs b/src-tauri/src/infrastructure/http/encryption/rsa.rs deleted file mode 100644 index 8fe635d8..00000000 --- a/src-tauri/src/infrastructure/http/encryption/rsa.rs +++ /dev/null @@ -1,88 +0,0 @@ -use std::fmt::Debug; - -use base64::Engine; -use rsa::{ - Pkcs1v15Encrypt, RsaPrivateKey, RsaPublicKey, - pkcs1::{DecodeRsaPublicKey, EncodeRsaPublicKey}, -}; - -#[derive(Debug, Clone)] -pub struct RsaSecret { - pub private_key: RsaPrivateKey, - pub public_key: RsaPublicKey, -} - -impl RsaSecret { - pub fn new() -> Result { - let mut rng = rsa::rand_core::OsRng::default(); - - let bits = 2048; - let private_key = RsaPrivateKey::new(&mut rng, bits)?; - let public_key = private_key.to_public_key(); - - Ok(RsaSecret { - private_key, - public_key, - }) - } - - /// 获取公钥对 - pub fn get_public_key(&self) -> Result { - self.public_key.to_pkcs1_pem(rsa::pkcs8::LineEnding::LF).map_err(|e| { - log::error!("Failed to encode public key: {:?}", e); - anyhow::anyhow!("Failed to encode public key") - }) - } - - /// 获取私钥对 - pub fn get_private_key(&self) -> RsaPrivateKey { - self.private_key.clone() - } - - /// 解密为默认的&[u8] - pub fn decrypt(&self, data: &str) -> Result, anyhow::Error> { - // base64 解码 - let data = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| { - log::error!("Failed to decode base64: {:?}", e); - anyhow::anyhow!("Failed to decode base64") - })?; - - let private_key = &self.private_key; - let decrypted_data = private_key.decrypt(Pkcs1v15Encrypt, &data)?; - - Ok(decrypted_data) - } - - /// 使用公钥加密 - pub fn encrypt(&self, data: &[u8]) -> Result { - let public_key = &self.private_key.to_public_key(); - - let mut rng = rsa::rand_core::OsRng::default(); - let encrypted_data = public_key.encrypt(&mut rng, Pkcs1v15Encrypt, data).map_err(|e| { - log::error!("Failed to encrypt data: {:?}", e); - anyhow::anyhow!("Failed to encrypt data") - })?; - - let encoded_data = base64::engine::general_purpose::STANDARD.encode(&encrypted_data); - - Ok(encoded_data) - } - - /// 使用公钥加密, 根据接收到的公钥 - pub fn encrypt_with_public_key(data: &[u8], public_key: &str) -> Result { - let public_key = rsa::RsaPublicKey::from_pkcs1_pem(public_key).map_err(|e| { - log::error!("Failed to parse public key: {:?}", e); - anyhow::anyhow!("Failed to parse public key") - })?; - - let mut rng = rsa::rand_core::OsRng::default(); - let encrypted_data = public_key.encrypt(&mut rng, Pkcs1v15Encrypt, data).map_err(|e| { - log::error!("Failed to encrypt data: {:?}", e); - anyhow::anyhow!("Failed to encrypt data") - })?; - - let encoded_data = base64::engine::general_purpose::STANDARD.encode(&encrypted_data); - - Ok(encoded_data) - } -} diff --git a/src-tauri/src/infrastructure/http/mod.rs b/src-tauri/src/infrastructure/http/mod.rs index ec17c2f6..8927392f 100644 --- a/src-tauri/src/infrastructure/http/mod.rs +++ b/src-tauri/src/infrastructure/http/mod.rs @@ -1,8 +1,3 @@ -/// HTTP 客户端基础模块 +/// HTTP response types shared by the Tauri command and local API layers. pub mod client; - -/// HTTP 加密模块 -pub mod encryption; - -// 重导出常用类型 -pub use client::{AfterCallFunction, BeforeCallFunction, Client, JsonRespnse}; +pub use client::JsonRespnse; diff --git a/src-tauri/src/infrastructure/main_server/client.rs b/src-tauri/src/infrastructure/main_server/client.rs deleted file mode 100644 index 66e5d68f..00000000 --- a/src-tauri/src/infrastructure/main_server/client.rs +++ /dev/null @@ -1,214 +0,0 @@ -use sha2::Sha256; - -use crate::infrastructure::http::client::JsonRespnse; -use crate::infrastructure::http::client::{AfterCallFunction, BeforeCallFunction, Client}; - -use crate::infrastructure::main_server::error::{ - is_public_key_expired_error, is_unauthorized_error, -}; -use crate::infrastructure::main_server::interceptors; -use crate::infrastructure::main_server::response::{build_url, parse_and_decrypt_response}; - -const TIMEOUT_DURATION_SECS: u64 = 35; - -/// 缓存配置 -const CACHE_TTL_SECS: u64 = 300; // 5 分钟 - -/// 自动重试宏:处理 401 和 422 错误 -macro_rules! with_auto_retry { - // 统一处理:支持 0 个或多个额外参数 - ($self:expr, $method_without_retry:ident, $url:expr $(, $args:expr)*) => {{ - match $self.$method_without_retry($url $(, $args)*).await { - Ok(resp) => Ok(resp), - - // 处理 401:刷新凭证 - Err(e) if is_unauthorized_error(&e) => { - if let Ok(_) = crate::infrastructure::persistence::credential::refresh_credentials().await { - $self.$method_without_retry($url $(, $args)*).await - } else { - Err(e) - } - } - - // 处理 422 + LASDE:刷新公钥 - Err(e) if is_public_key_expired_error(&e) => { - if let Ok(_) = crate::infrastructure::persistence::credential::fetch_server_public_key().await { - // 公钥已刷新,用新公钥重新加密原始数据并重试 - $self.$method_without_retry($url $(, $args)*).await - } else { - Err(e) - } - } - - Err(e) => Err(e), - } - }}; -} - -/// 主服务器的请求客户端 -pub struct MainServerRequestClient { - client: Client, -} - -impl MainServerRequestClient { - pub fn new() -> MainServerRequestClient { - MainServerRequestClient { - client: Client::new(TIMEOUT_DURATION_SECS), - } - } - - /// 发起 GET 请求(带自动凭证刷新) - pub async fn get(&self, url: &str) -> core::result::Result { - with_auto_retry!(self, get_no_retry, url) - } - - // 内部方法:不带刷新的 GET(仅供宏使用) - async fn get_no_retry(&self, url: &str) -> core::result::Result { - // 发起实际请求 - let response = self.client.get(build_url(url)?).await?; - parse_and_decrypt_response(response).await - } - - /// 发起 POST 请求(带自动凭证刷新) - pub async fn post( - &self, - url: &str, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize + std::fmt::Debug, - { - with_auto_retry!(self, post_no_retry, url, json) - } - - pub async fn post_no_retry( - &self, - url: &str, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize + std::fmt::Debug, - { - // 发起实际请求 - let response = self.client.post(build_url(url)?, json).await?; - parse_and_decrypt_response(response).await - } - - pub async fn post_with_headers( - &self, - url: &str, - json: &T, - headers: reqwest::header::HeaderMap, - ) -> core::result::Result - where - T: serde::Serialize + std::fmt::Debug, - { - match self.post_with_headers_no_retry(url, json, headers.clone()).await { - Ok(resp) => Ok(resp), - Err(e) if is_unauthorized_error(&e) => { - if let Ok(_) = - crate::infrastructure::persistence::credential::refresh_credentials().await - { - self.post_with_headers_no_retry(url, json, headers).await - } else { - Err(e) - } - } - Err(e) if is_public_key_expired_error(&e) => { - if let Ok(_) = - crate::infrastructure::persistence::credential::fetch_server_public_key().await - { - self.post_with_headers_no_retry(url, json, headers).await - } else { - Err(e) - } - } - Err(e) => Err(e), - } - } - - async fn post_with_headers_no_retry( - &self, - url: &str, - json: &T, - headers: reqwest::header::HeaderMap, - ) -> core::result::Result - where - T: serde::Serialize + std::fmt::Debug, - { - let response = self.client.post_with_headers(build_url(url)?, json, headers).await?; - parse_and_decrypt_response(response).await - } - - /// 发起 POST 表单请求(用于文件上传) - /// - /// 注意:由于 Form 无法克隆,此方法不支持自动重试 - pub async fn post_form( - &self, - url: &str, - form: reqwest::multipart::Form, - ) -> core::result::Result { - let response = self.client.post_form(build_url(url)?, form).await?; - parse_and_decrypt_response(response).await - } - - /// 发起 PUT 请求(带自动凭证刷新) - pub async fn put( - &self, - url: &str, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize, - { - with_auto_retry!(self, put_no_retry, url, json) - } - - // 内部方法:不带刷新的 PUT(仅供宏使用) - async fn put_no_retry( - &self, - url: &str, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize, - { - let response = self.client.put(build_url(url)?, json).await?; - parse_and_decrypt_response(response).await - } - - /// 发起 DELETE 请求(带自动凭证刷新) - pub async fn delete( - &self, - url: &str, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize, - { - with_auto_retry!(self, delete_no_retry, url, json) - } - - // 内部方法:不带刷新的 DELETE(仅供宏使用) - async fn delete_no_retry( - &self, - url: &str, - json: &T, - ) -> core::result::Result - where - T: serde::Serialize, - { - let response = self.client.delete(build_url(url)?, json).await?; - parse_and_decrypt_response(response).await - } - - // 请求拦截器 - pub fn before(&mut self, call: BeforeCallFunction) { - self.client.before(call); - } - - // 响应拦截器 - pub fn after(&mut self, call: AfterCallFunction) { - self.client.after(call); - } -} diff --git a/src-tauri/src/infrastructure/main_server/error.rs b/src-tauri/src/infrastructure/main_server/error.rs deleted file mode 100644 index 244740db..00000000 --- a/src-tauri/src/infrastructure/main_server/error.rs +++ /dev/null @@ -1,66 +0,0 @@ -/// 错误处理模块 -use thiserror::Error; - -/// HTTP 响应错误类型 -#[derive(Debug, Error)] -pub enum ResponseError { - /// 未授权错误 (401) - #[error("未授权访问 (401): {message}")] - Unauthorized { message: String }, - - /// 请求参数错误 (400) - #[error("请求参数错误 (400): {message}")] - BadRequest { message: String }, - - /// 公钥过期错误 (422 + LASDE) - #[error("公钥已过期 (422): 需要重新获取公钥")] - PublicKeyExpired, - - /// 无法处理的实体错误 (422) - #[error("无法处理的请求 (422): {message}")] - UnprocessableEntity { message: String }, - - /// 响应体为空 - #[error("服务器响应为空 ({status}): 服务器未返回任何数据")] - EmptyResponse { status: u16 }, - - /// JSON 解析失败 - #[error("JSON 解析失败 ({status}): {parse_error}. {detail}")] - JsonParseError { - status: u16, - parse_error: String, - detail: String, - }, - - /// 读取响应失败 - #[error("读取响应失败: {0}")] - ReadResponseFailed(String), - - /// 解析加密数据失败 - #[error("解析加密数据失败: {0}")] - ParseEncryptedDataFailed(String), - - /// 解密失败 - #[error("解密失败: {0}")] - DecryptFailed(String), - - /// 解析响应失败 - #[error("解析响应失败: {0}")] - ParseResponseFailed(String), -} - -/// 检查是否是 401 未授权错误 -pub fn is_unauthorized_error(err: &anyhow::Error) -> bool { - err.downcast_ref::() - .map(|e| matches!(e, ResponseError::Unauthorized { .. })) - .unwrap_or(false) - || err.to_string().contains("StatusCode(401)") -} - -/// 检查是否是公钥过期错误(422 + LASDE) -pub fn is_public_key_expired_error(err: &anyhow::Error) -> bool { - err.downcast_ref::() - .map(|e| matches!(e, ResponseError::PublicKeyExpired)) - .unwrap_or(false) - || err.to_string().contains("StatusCode(422):LASDE") -} diff --git a/src-tauri/src/infrastructure/main_server/interceptors/crypto.rs b/src-tauri/src/infrastructure/main_server/interceptors/crypto.rs deleted file mode 100644 index e2597f6a..00000000 --- a/src-tauri/src/infrastructure/main_server/interceptors/crypto.rs +++ /dev/null @@ -1,49 +0,0 @@ -/// 加密解密辅助模块 -use crate::app::context::AppContext; -use crate::infrastructure::http::encryption::AesSecret; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -/// 加密的数据结构 -#[derive(Debug, Deserialize, Serialize)] -pub struct EncryptedData { - pub encrypted: bool, - #[serde(default)] - pub data: String, - #[serde(default)] - pub key: String, -} - -/// 解密加密的数据 -pub fn decrypt_if_encrypted(encrypted_data: &EncryptedData) -> Result { - // 如果没有加密,直接解析 data 字段 - if !encrypted_data.encrypted { - return serde_json::from_str(&encrypted_data.data) - .map_err(|_| "Failed to parse data".to_string()); - } - - let ctx = AppContext::get(); - - // 解密 AES 密钥 - let aes_key_bytes = ctx - .rsa_keypair - .decrypt(&encrypted_data.key) - .map_err(|_| "Failed to decrypt AES key")?; - - let aes_key_str = - std::str::from_utf8(&aes_key_bytes).map_err(|_| "Failed to convert AES key to string")?; - - // 创建 AES 实例 - let aes_secret = - AesSecret::try_from(aes_key_str).map_err(|_| "Failed to create AES instance")?; - - // 解密数据 - let decrypted_bytes = - aes_secret.decrypt(&encrypted_data.data).map_err(|_| "Failed to decrypt data")?; - - // 解析为 JSON - let decrypted_value: Value = - serde_json::from_slice(&decrypted_bytes).map_err(|_| "Failed to parse decrypted data")?; - - Ok(decrypted_value) -} diff --git a/src-tauri/src/infrastructure/main_server/interceptors/mod.rs b/src-tauri/src/infrastructure/main_server/interceptors/mod.rs deleted file mode 100644 index c0664239..00000000 --- a/src-tauri/src/infrastructure/main_server/interceptors/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -/// 拦截器模块 -/// -/// 负责请求和响应的拦截处理,包括加密、认证等 -pub mod crypto; -pub mod request; - -/// 响应拦截器(保持接口,实际解密在 response 模块处理) -pub async fn response_interceptor( - response: reqwest::Response, -) -> core::result::Result { - // 直接返回,实际解密由 response 模块处理 - Ok(response) -} diff --git a/src-tauri/src/infrastructure/main_server/interceptors/request.rs b/src-tauri/src/infrastructure/main_server/interceptors/request.rs deleted file mode 100644 index c3ca5fa7..00000000 --- a/src-tauri/src/infrastructure/main_server/interceptors/request.rs +++ /dev/null @@ -1,123 +0,0 @@ -/// 请求拦截器模块 -use reqwest::header::AUTHORIZATION; -use serde_json::Value; - -use crate::app::context::AppContext; -use crate::infrastructure::http::encryption::{AesSecret, RsaSecret}; - -use crate::infrastructure::persistence::credential::{CREDENTIAL, SERVER_PUBLIC_KEY}; - -/// 加密拦截器 -pub async fn encrypt( - rb: reqwest::RequestBuilder, -) -> core::result::Result { - // 获取请求的 URL,检查是否是获取公钥的请求 - let url = rb - .try_clone() - .and_then(|r| r.build().ok()) - .and_then(|req| Some(req.url().to_string())) - .unwrap_or_default(); - - // 如果是获取公钥的请求,直接返回,不加密 - if url.contains("/secret/public/key") { - return Ok(rb); - } - - // 尝试获取并提取请求体 - let cloned_rb = match rb.try_clone() { - Some(r) => r, - None => return Ok(rb), // 无法克隆,直接返回原请求 - }; - - // 检查是否有 JSON 请求体 - let built_request = match cloned_rb.build() { - Ok(req) => req, - Err(_) => return Ok(rb), - }; - - let body = match built_request.body() { - Some(b) => b.as_bytes().unwrap_or(&[]), - None => return Ok(rb), - }; - - if body.is_empty() { - return Ok(rb); - } - - // 解析请求体为 JSON - let mut json_body: Value = match serde_json::from_slice(body) { - Ok(v) => v, - Err(_) => return Ok(rb), // 不是 JSON,不处理 - }; - - // 添加 api_secret - if let Some(obj) = json_body.as_object_mut() { - let ctx = AppContext::get(); - obj.insert( - "api_secret".to_string(), - Value::String(ctx.config.server.secret_key.clone()), - ); - } - - // 获取服务器公钥(必须已初始化) - let public_key = match SERVER_PUBLIC_KEY.read().unwrap().clone() { - Some(key) => key, - None => { - return Ok(rb); // 公钥未初始化,不加密 - } - }; - - // 加密数据 - let encrypted_body = match encrypt_request_body(&json_body, &public_key) { - Ok(body) => body, - Err(_) => { - return Ok(rb); // 加密失败,返回原请求 - } - }; - - // 重新构建带加密数据的请求 - Ok(rb.json(&encrypted_body)) -} - -/// 认证拦截器 -pub async fn auth( - rb: reqwest::RequestBuilder, -) -> core::result::Result { - let mut headers = reqwest::header::HeaderMap::new(); - - if let Ok(cred) = CREDENTIAL.read() { - if let Some(token) = cred.get_access_token() { - if let Ok(token) = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token)) - { - headers.insert(AUTHORIZATION, token); - } - } - } - - Ok(rb.headers(headers)) -} - -/// 加密请求体 -fn encrypt_request_body(data: &Value, public_key: &str) -> Result { - // 创建 AES 密钥 - let aes_secret = AesSecret::new(); - - // 序列化数据 - let json_string = serde_json::to_string(data).map_err(|_| "Failed to serialize data")?; - - // AES 加密数据 - let encrypted_data = aes_secret - .encrypt(json_string.as_bytes()) - .map_err(|_| "AES encryption failed")?; - - // RSA 加密 AES 密钥 - let encrypted_key = - RsaSecret::encrypt_with_public_key(aes_secret.get_key_as_base64().as_bytes(), public_key) - .map_err(|_| "RSA encryption failed")?; - - Ok(serde_json::json!({ - "data": encrypted_data, - "encrypted": true, - "key": encrypted_key - })) -} diff --git a/src-tauri/src/infrastructure/main_server/mod.rs b/src-tauri/src/infrastructure/main_server/mod.rs deleted file mode 100644 index 177a62ec..00000000 --- a/src-tauri/src/infrastructure/main_server/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod client; -pub mod error; -pub mod interceptors; -pub mod response; -pub mod types; diff --git a/src-tauri/src/infrastructure/main_server/response.rs b/src-tauri/src/infrastructure/main_server/response.rs deleted file mode 100644 index 83974e82..00000000 --- a/src-tauri/src/infrastructure/main_server/response.rs +++ /dev/null @@ -1,142 +0,0 @@ -/// 响应解析和解密模块 -use crate::infrastructure::http::client::Client; -use crate::infrastructure::http::client::JsonRespnse; - -use crate::infrastructure::main_server::error::ResponseError; -use crate::infrastructure::main_server::interceptors::crypto::{ - EncryptedData, decrypt_if_encrypted, -}; - -/// 构建完整的 API URL -pub fn build_url(resource: &str) -> Result { - Client::build_url(resource) -} - -/// 解析并解密响应(业务层逻辑) -pub async fn parse_and_decrypt_response( - response: reqwest::Response, -) -> core::result::Result { - let status = response.status(); - let status_code = status.as_u16(); - - // 检查 HTTP 401 - if status == reqwest::StatusCode::UNAUTHORIZED { - let body_text = response.text().await.unwrap_or_default(); - let message = - extract_error_message(&body_text).unwrap_or_else(|| "请检查登录状态".to_string()); - return Err(ResponseError::Unauthorized { message }.into()); - } - - // 检查 HTTP 400 - if status == reqwest::StatusCode::BAD_REQUEST { - let body_text = response.text().await.unwrap_or_default(); - let message = - extract_error_message(&body_text).unwrap_or_else(|| "请求参数错误".to_string()); - return Err(ResponseError::BadRequest { message }.into()); - } - - // 检查 HTTP 422(可能是公钥过期) - if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY { - // 先读取响应体 - let body_text = response - .text() - .await - .map_err(|e| ResponseError::ReadResponseFailed(e.to_string()))?; - - // 尝试解析并检查是否是公钥过期错误 - if let Ok(body_json) = serde_json::from_str::(&body_text) { - if let Some(message) = body_json.get("message").and_then(|m| m.as_str()) { - if message.contains("LASDE") { - // 公钥过期错误 - return Err(ResponseError::PublicKeyExpired.into()); - } - } - } - - // 其他 422 错误,尝试正常解析 - let encrypted_data: EncryptedData = - serde_json::from_str(&body_text).map_err(|e| ResponseError::UnprocessableEntity { - message: format!("解析响应失败: {}", e), - })?; - - return decrypt_response_data(encrypted_data); - } - - // 正常响应,先解析为 JSON Value 判断是否加密 - let body_text = response - .text() - .await - .map_err(|e| ResponseError::ReadResponseFailed(e.to_string()))?; - - // 检查响应体是否为空 - if body_text.is_empty() { - return Err(ResponseError::EmptyResponse { - status: status_code, - } - .into()); - } - - // 先解析为通用 JSON - let body_value: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| { - let detail = extract_error_message(&body_text).unwrap_or_else(|| { - format!( - "响应数据: {}", - body_text.chars().take(200).collect::() - ) - }); - ResponseError::JsonParseError { - status: status_code, - parse_error: e.to_string(), - detail, - } - })?; - - // 检查是否有 encrypted 字段且为 true - let is_encrypted = body_value.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false); - if is_encrypted { - // 是加密响应,解析为 EncryptedData 并解密 - let encrypted_data: EncryptedData = serde_json::from_value(body_value) - .map_err(|e| ResponseError::ParseEncryptedDataFailed(e.to_string()))?; - decrypt_response_data(encrypted_data) - } else { - // 未加密,直接解析为 JsonResponse - serde_json::from_value(body_value) - .map_err(|e| ResponseError::ParseResponseFailed(e.to_string()).into()) - } -} - -/// 解密响应数据 -fn decrypt_response_data( - encrypted_data: EncryptedData, -) -> core::result::Result { - // 检查是否加密 - if encrypted_data.encrypted { - // 解密数据 - let decrypted_value = decrypt_if_encrypted(&encrypted_data) - .map_err(|e| ResponseError::DecryptFailed(e.to_string()))?; - - // 解析为 JsonRespnse - serde_json::from_value(decrypted_value) - .map_err(|e| ResponseError::ParseResponseFailed(e.to_string()).into()) - } else { - // 未加密,直接解析 data 字段 - serde_json::from_str(&encrypted_data.data).map_err(|e| { - ResponseError::ParseResponseFailed(format!("{}, 数据: {}", e, encrypted_data.data)) - .into() - }) - } -} - -/// 从响应体中提取错误信息 -fn extract_error_message(body_text: &str) -> Option { - if body_text.is_empty() { - return None; - } - - serde_json::from_str::(body_text).ok().and_then(|json| { - json.get("message") - .and_then(|m| m.as_str()) - .map(|s| s.to_string()) - .or_else(|| json.get("error").and_then(|e| e.as_str()).map(|s| s.to_string())) - }) -} diff --git a/src-tauri/src/infrastructure/main_server/types.rs b/src-tauri/src/infrastructure/main_server/types.rs deleted file mode 100644 index 6109aeb6..00000000 --- a/src-tauri/src/infrastructure/main_server/types.rs +++ /dev/null @@ -1,27 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -/// 标准 JSON 响应结构 -#[derive(Deserialize, Debug, Serialize)] -pub struct JsonRespnse { - pub code: Option, - pub message: Option, - pub data: Option, -} - -/// 可能加密的响应数据 -#[derive(Deserialize, Debug, Serialize)] -#[serde(untagged)] -#[allow(dead_code)] -pub enum MaybeEncryptedResponse { - /// 加密的响应 - Encrypted { - encrypted: bool, - #[serde(default)] - data: String, - #[serde(default)] - key: String, - }, - /// 普通的 JSON 响应 - Plain(JsonRespnse), -} diff --git a/src-tauri/src/infrastructure/mihomo/models.rs b/src-tauri/src/infrastructure/mihomo/models.rs index 207060b1..a0fee350 100644 --- a/src-tauri/src/infrastructure/mihomo/models.rs +++ b/src-tauri/src/infrastructure/mihomo/models.rs @@ -235,8 +235,5 @@ fn detect_clash_verge_rev_config_dir() -> Option { } fn resolve_mihomo_config_path(config_dir: PathBuf) -> String { - config_dir - .join(MIHOMO_CONFIG_FILE_PRIMARY) - .to_string_lossy() - .to_string() + config_dir.join(MIHOMO_CONFIG_FILE_PRIMARY).to_string_lossy().to_string() } diff --git a/src-tauri/src/infrastructure/mod.rs b/src-tauri/src/infrastructure/mod.rs index 0e2457e2..635453eb 100644 --- a/src-tauri/src/infrastructure/mod.rs +++ b/src-tauri/src/infrastructure/mod.rs @@ -1,8 +1,5 @@ -pub mod deeplink; pub mod http; -pub mod main_server; pub mod mihomo; pub mod persistence; pub mod proxy; pub mod runtime; -pub mod updater; diff --git a/src-tauri/src/infrastructure/persistence/credential/mod.rs b/src-tauri/src/infrastructure/persistence/credential/mod.rs deleted file mode 100644 index 35adb8ae..00000000 --- a/src-tauri/src/infrastructure/persistence/credential/mod.rs +++ /dev/null @@ -1,201 +0,0 @@ -pub mod remembered; - -use log::error; -use once_cell::sync::Lazy; -use std::sync::{Arc, RwLock}; -use tokio::sync::Mutex; - -/// 凭证信息 -#[derive(Debug, Clone, Default)] -pub struct Credential { - access_token: Option, - refresh_token: Option, -} - -impl Credential { - pub fn get_access_token(&self) -> Option { - self.access_token.clone() - } - - pub fn get_refresh_token(&self) -> Option { - self.refresh_token.clone() - } - - // 重置token - pub fn reset_token(&mut self) { - self.access_token = None; - self.refresh_token = None; - } - - // 是否处于登录状态 - pub fn is_login(&self) -> bool { - self.access_token.is_some() - } -} - -/// 全局凭证存储 -pub(crate) static CREDENTIAL: Lazy> = - Lazy::new(|| RwLock::new(Credential::default())); - -/// 服务器公钥存储 -pub(crate) static SERVER_PUBLIC_KEY: Lazy>> = Lazy::new(|| RwLock::new(None)); - -/// 刷新锁:确保同一时间只有一个请求在刷新凭证 -static REFRESH_LOCK: Lazy>> = Lazy::new(|| Arc::new(Mutex::new(()))); - -// ============ 凭证管理函数 ============ - -/// 获取凭证 -pub fn get_credential() -> Credential { - CREDENTIAL.read().unwrap().clone() -} - -/// 设置访问令牌 -pub fn set_access_token(token: String) { - CREDENTIAL.write().unwrap().access_token = Some(token); -} - -/// 设置刷新令牌 -pub fn set_refresh_token(token: String) { - CREDENTIAL.write().unwrap().refresh_token = Some(token); -} - -/// 设置完整凭证 -pub fn set_credential(access_token: String, refresh_token: String) { - let mut cred = CREDENTIAL.write().unwrap(); - cred.access_token = Some(access_token); - cred.refresh_token = Some(refresh_token); -} - -/// 清除凭证(登出) -pub fn clear_credential() { - CREDENTIAL.write().unwrap().reset_token(); -} - -/// 是否已登录 -pub fn is_login() -> bool { - CREDENTIAL.read().unwrap().is_login() -} - -// ============ 服务器公钥管理函数 ============ - -/// 获取/刷新服务器公钥(应用启动时或公钥过期时调用) -/// 支持自动重试,最多重试 3 次 -pub async fn fetch_server_public_key() -> Result<(), String> { - use crate::infrastructure::http::client::Client; - use tokio::time::{Duration, sleep}; - - let url = Client::build_url("secret/public/key").map_err(|_| { - error!("服务器请求路径错误"); - "初始化失败".to_string() - })?; - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .map_err(|_| "初始化失败".to_string())?; - - let mut last_err = None; - for attempt in 0..3 { - if attempt > 0 { - sleep(Duration::from_millis(1000 * attempt as u64)).await; - } - - match client.get(url.as_str()).send().await { - Ok(response) => { - if !response.status().is_success() { - last_err = Some("初始化失败".to_string()); - continue; - } - - match response.text().await { - Ok(public_key) => { - if public_key.contains("-----BEGIN RSA PUBLIC KEY-----") { - *SERVER_PUBLIC_KEY.write().unwrap() = Some(public_key); - return Ok(()); - } else { - error!("服务器响应错误"); - return Err("初始化失败".to_string()); - } - } - Err(_) => { - last_err = Some("初始化失败".to_string()); - continue; - } - } - } - Err(_) => { - last_err = Some("初始化失败".to_string()); - continue; - } - } - } - - error!("获取服务器公钥失败,已达到最大重试次数"); - Err(last_err.unwrap_or_else(|| "初始化失败".to_string())) -} - -/// 初始化服务器公钥(应用启动时调用,是 fetch_server_public_key 的别名) -#[inline] -pub async fn init_server_public_key() -> Result<(), String> { - fetch_server_public_key().await -} - -/// 获取服务器公钥(必须在初始化后调用) -pub fn get_server_public_key() -> String { - SERVER_PUBLIC_KEY.read().unwrap().clone().expect("服务器配置未初始化") -} - -/// 设置服务器公钥 -pub fn set_server_public_key(public_key: String) { - *SERVER_PUBLIC_KEY.write().unwrap() = Some(public_key); -} - -/// 清除服务器公钥 -pub fn clear_server_public_key() { - *SERVER_PUBLIC_KEY.write().unwrap() = None; -} - -// ============ 凭证刷新函数 ============ - -/// 刷新用户凭证(带并发控制) -pub async fn refresh_credentials() -> Result<(), String> { - // 使用 Box::pin 避免递归检测 - Box::pin(async { - // 获取锁,确保同一时间只有一个请求在刷新 - let _lock = REFRESH_LOCK.lock().await; - - // 获取当前的 refresh_token - let refresh_token = get_credential().get_refresh_token().ok_or("无刷新令牌")?; - - // 使用 AppContext 中的 HTTP 客户端发送刷新请求(避免递归) - let ctx = crate::app::context::AppContext::get(); - let response = ctx - .main_server_client - .post_no_retry( - "users/refresh-credentials", - &serde_json::json!({ - "refresh_token": refresh_token - }), - ) - .await - .map_err(|_| "刷新凭证失败")?; - - // 检查响应状态 - if response.code != Some(1) { - return Err("凭证刷新失败".to_string()); - } - - // 提取新凭证 - let data = response.data.ok_or("响应无数据")?; - let access_token = data.get("access_token").and_then(|t| t.as_str()).ok_or("无访问令牌")?; - let new_refresh_token = - data.get("refresh_token").and_then(|t| t.as_str()).ok_or("无刷新令牌")?; - - // 更新全局凭证 - set_credential(access_token.to_string(), new_refresh_token.to_string()); - - Ok(()) - }) - .await -} diff --git a/src-tauri/src/infrastructure/persistence/credential/remembered.rs b/src-tauri/src/infrastructure/persistence/credential/remembered.rs deleted file mode 100644 index 64fe4a45..00000000 --- a/src-tauri/src/infrastructure/persistence/credential/remembered.rs +++ /dev/null @@ -1,66 +0,0 @@ -/// 记住的凭证持久化存储模块 -/// -/// 使用系统凭据存储保存 email 和 refresh_token -use anyhow::{Context, Result}; -use keyring::{Entry, Error as KeyringError}; -use serde::{Deserialize, Serialize}; - -const REMEMBERED_CREDENTIAL_SERVICE: &str = "simprint"; -const REMEMBERED_CREDENTIAL_ACCOUNT: &str = "remembered-session"; - -/// 记住的凭证数据结构 -#[derive(Debug, Clone, Serialize, Deserialize)] -struct RememberedCredential { - email: String, - refresh_token: String, - saved_at: i64, // Unix timestamp -} - -fn remembered_entry() -> Result { - Entry::new(REMEMBERED_CREDENTIAL_SERVICE, REMEMBERED_CREDENTIAL_ACCOUNT) - .context("创建系统凭据条目失败") -} - -/// 保存记住的凭证 -pub fn save_remembered_credential(email: String, refresh_token: String) -> Result<()> { - let credential = RememberedCredential { - email, - refresh_token, - saved_at: chrono::Utc::now().timestamp(), - }; - - let payload = serde_json::to_string(&credential) - .map_err(|_| anyhow::anyhow!("Failed to serialize credential data"))?; - let entry = remembered_entry()?; - entry.set_password(&payload).context("保存系统凭据失败")?; - - log::debug!("凭证存储操作完成"); - Ok(()) -} - -/// 读取记住的凭证 -pub fn get_remembered_credential() -> Result> { - let entry = remembered_entry()?; - let payload = match entry.get_password() { - Ok(payload) => payload, - Err(KeyringError::NoEntry) => return Ok(None), - Err(error) => return Err(anyhow::Error::new(error).context("读取系统凭据失败")), - }; - - let credential: RememberedCredential = serde_json::from_str(&payload) - .map_err(|_| anyhow::anyhow!("Failed to deserialize credential data"))?; - - Ok(Some((credential.email, credential.refresh_token))) -} - -/// 清除记住的凭证 -pub fn clear_remembered_credential() -> Result<()> { - let entry = remembered_entry()?; - match entry.delete_credential() { - Ok(()) | Err(KeyringError::NoEntry) => { - log::debug!("凭证清除操作完成"); - Ok(()) - } - Err(error) => Err(anyhow::Error::new(error).context("删除系统凭据失败")), - } -} diff --git a/src-tauri/src/infrastructure/persistence/mod.rs b/src-tauri/src/infrastructure/persistence/mod.rs index 5d6bfad7..21dc2d51 100644 --- a/src-tauri/src/infrastructure/persistence/mod.rs +++ b/src-tauri/src/infrastructure/persistence/mod.rs @@ -1,2 +1 @@ -pub mod credential; pub mod tauri_store; diff --git a/src-tauri/src/infrastructure/runtime/mod.rs b/src-tauri/src/infrastructure/runtime/mod.rs index bb85d122..b057103a 100644 --- a/src-tauri/src/infrastructure/runtime/mod.rs +++ b/src-tauri/src/infrastructure/runtime/mod.rs @@ -9,8 +9,8 @@ pub use api::{ BatchLaunchResult, CdpEndpointResponse, CookieGroup, DestroyContextRequest, EmptyPayload, EnvConnectionPayload, EnvironmentCommandRequest, EnvironmentCommandResponse, EnvironmentResponse, EnvironmentStartRequest, ErrorResponse, FingerprintConfig, - HandshakeRequest, HandshakeResponse, InitializeContextRequest, RpaTabCloseResult, - RpaTabInfo, RpaTabSelection, RpaTabsSnapshot, RunningEnvironment, RuntimeContextInput, + HandshakeRequest, HandshakeResponse, InitializeContextRequest, RpaTabCloseResult, RpaTabInfo, + RpaTabSelection, RpaTabsSnapshot, RunningEnvironment, RuntimeContextInput, RuntimeEventEnvelope, RuntimePhase, RuntimeStateSnapshot, StateResponse, SyncCommandRequest, SyncCommandResponse, SyncResponse, UserInfo, WindowBoundsRequest, }; diff --git a/src-tauri/src/infrastructure/updater/checker.rs b/src-tauri/src/infrastructure/updater/checker.rs deleted file mode 100644 index d72da98a..00000000 --- a/src-tauri/src/infrastructure/updater/checker.rs +++ /dev/null @@ -1,45 +0,0 @@ -/// 版本检查模块 -/// -/// 负责调用服务器的版本检查 API 获取更新信息 -use crate::app::context::AppContext; -use crate::infrastructure::updater::types::{CheckRequest, CheckResponse}; -use anyhow::{Context, Result}; -use reqwest::Client; -use std::time::Duration; - -const CHECK_TIMEOUT_SECS: u64 = 30; - -/// 检查更新 -/// -/// # 返回 -/// 返回服务器检查响应 -pub async fn check_updates() -> Result { - let ctx = AppContext::get(); - let url = &ctx.config.updater.check_url; - - // 创建 HTTP 客户端 - let client = Client::builder() - .timeout(Duration::from_secs(CHECK_TIMEOUT_SECS)) - .build() - .context("创建 HTTP 客户端失败")?; - - // 构建请求 - let request = CheckRequest {}; - - let response = client.post(url).json(&request).send().await.context("发送检查请求失败")?; - - // 检查 HTTP 状态码 - let status = response.status(); - if !status.is_success() { - return Err(anyhow::anyhow!("服务器返回错误状态码: {}", status)); - } - - let response_json = response.json::().await?; - // println!("检查响应: {:?}", response_json); - - // 解析响应 - let check_response: CheckResponse = - serde_json::from_value(response_json).context("解析服务器响应失败")?; - - Ok(check_response) -} diff --git a/src-tauri/src/infrastructure/updater/downloader.rs b/src-tauri/src/infrastructure/updater/downloader.rs deleted file mode 100644 index e9c5e8b6..00000000 --- a/src-tauri/src/infrastructure/updater/downloader.rs +++ /dev/null @@ -1,136 +0,0 @@ -/// 下载模块 -/// -/// 负责下载更新文件到临时目录,支持重试和进度回调 -use crate::infrastructure::updater::types::UpdateTask; -use anyhow::{Context, Result}; -use futures::StreamExt; -use reqwest::{Client, header}; -use std::fs::File; -use std::io::Write; -use std::time::Duration; - -const DOWNLOAD_TIMEOUT_SECS: u64 = 300; // 5 分钟超时 -const MAX_RETRIES: u32 = 3; -const RETRY_DELAY_SECS: u64 = 2; - -/// 下载单个文件(带进度回调) -/// -/// # 参数 -/// - `task`: 更新任务 -/// - `client`: HTTP 客户端 -/// - `on_progress`: 进度回调函数,参数为已下载字节数 -/// -/// # 返回 -/// 返回下载文件的临时路径 -pub async fn download_file_with_progress( - task: &UpdateTask, - client: &Client, - mut on_progress: F, -) -> Result<()> -where - F: FnMut(u64) -> Result<()>, -{ - let mut last_error = None; - - // 重试循环 - for attempt in 1..=MAX_RETRIES { - match download_file_internal_with_progress(task, client, &mut on_progress).await { - Ok(_) => return Ok(()), - Err(e) => { - last_error = Some(e); - - // 清理临时文件 - if task.temp_path.exists() { - let _ = std::fs::remove_file(&task.temp_path); - } - - if attempt < MAX_RETRIES { - tokio::time::sleep(Duration::from_secs(RETRY_DELAY_SECS)).await; - } - } - } - } - - Err(anyhow::anyhow!( - "下载文件失败,已达到最大重试次数: {}", - last_error.unwrap_or_else(|| anyhow::anyhow!("未知错误")) - )) -} - -/// 内部下载实现(带进度回调) -async fn download_file_internal_with_progress( - task: &UpdateTask, - client: &Client, - on_progress: &mut F, -) -> Result<()> -where - F: FnMut(u64) -> Result<()>, -{ - // 确保临时目录存在 - if let Some(parent) = task.temp_path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("创建临时目录失败: {}", parent.display()))?; - } - - // 创建临时文件 - let mut file = File::create(&task.temp_path) - .with_context(|| format!("创建临时文件失败: {}", task.temp_path.display()))?; - - // 发送 HTTP GET 请求 - let response = client - .get(&task.artifact.url) - .timeout(Duration::from_secs(DOWNLOAD_TIMEOUT_SECS)) - .header(header::ACCEPT_ENCODING, "identity") - .send() - .await - .with_context(|| format!("发送下载请求失败"))?; - - // 检查 HTTP 状态码 - if !response.status().is_success() { - return Err(anyhow::anyhow!( - "服务器返回错误状态码: {}", - response.status() - )); - } - - // 获取内容长度(用于进度跟踪) - let content_length = response.content_length(); - if let Some(expected_size) = content_length { - if expected_size != task.artifact.file_size { - // 静默处理文件大小不匹配,不输出日志 - } - } - - // 流式下载并写入文件 - let mut stream = response.bytes_stream(); - let mut downloaded: u64 = 0; - - while let Some(chunk_result) = stream.next().await { - let chunk = chunk_result.with_context(|| "读取下载数据块失败")?; - - file.write_all(&chunk).with_context(|| "写入文件失败")?; - - downloaded += chunk.len() as u64; - - // 调用进度回调 - let _ = on_progress(downloaded); - } - - // 确保文件写入完成 - file.sync_all().with_context(|| "同步文件失败")?; - - // 验证下载的文件大小 - let actual_size = std::fs::metadata(&task.temp_path) - .with_context(|| "获取下载文件大小失败")? - .len(); - - if actual_size != task.artifact.file_size { - return Err(anyhow::anyhow!( - "下载文件大小不匹配: 期望 {}, 实际 {}", - task.artifact.file_size, - actual_size - )); - } - - Ok(()) -} diff --git a/src-tauri/src/infrastructure/updater/installer.rs b/src-tauri/src/infrastructure/updater/installer.rs deleted file mode 100644 index f1ade078..00000000 --- a/src-tauri/src/infrastructure/updater/installer.rs +++ /dev/null @@ -1,275 +0,0 @@ -/// 安装模块 -/// -/// 负责安全替换文件(备份、原子替换) -use anyhow::{Context, Result}; -use std::fs; -use std::path::Path; - -/// 检查进程是否正在运行(通过 resource_name 匹配,不检查路径) -fn is_process_running_by_name(resource_name: &str) -> Result { - use sysinfo::System; - - let resource_name_lower = resource_name.to_lowercase(); - let mut sys = System::new(); - sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); - - // 检查是否有匹配的进程(仅通过名称匹配) - let found = sys.processes().iter().any(|(_, process)| { - let process_name = process.name().to_string_lossy().to_lowercase(); - process_name == resource_name_lower - }); - - Ok(found) -} - -/// 查找并终止进程(通过 resource_name) -fn kill_process_by_name(resource_name: &str) -> Result<()> { - use sysinfo::System; - - let resource_name_lower = resource_name.to_lowercase(); - let mut sys = System::new(); - sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); - - // 查找所有匹配的进程并终止 - for (_, process) in sys.processes() { - let process_name = process.name().to_string_lossy().to_lowercase(); - - if process_name == resource_name_lower { - // 使用 sysinfo 的 kill 方法(静默执行,不输出日志) - let _ = process.kill(); - } - } - - Ok(()) -} - -/// 等待进程退出(通过 resource_name 匹配) -/// 最多等待 1.8 秒(每次 0.6 秒,总共 3 次) -/// 每次等待时都尝试终止进程 -fn wait_for_process_exit_by_name(resource_name: &str) -> Result<()> { - // 检查进程是否正在运行 - let is_running = is_process_running_by_name(resource_name).context("检查进程状态失败")?; - - if !is_running { - return Ok(()); - } - - const MAX_ATTEMPTS: u32 = 3; // 最多尝试 3 次 - const DELAY_MILLIS: u64 = 600; // 每次等待 0.6 秒 - - for _attempt in 1..=MAX_ATTEMPTS { - // 尝试终止进程 - let _ = kill_process_by_name(resource_name); - - // 等待 0.6 秒 - std::thread::sleep(std::time::Duration::from_millis(DELAY_MILLIS)); - - // 检查进程是否已退出 - let is_running = is_process_running_by_name(resource_name).context("检查进程状态失败")?; - - if !is_running { - return Ok(()); - } - } - - // 最后一次检查(静默处理,不输出日志) - let _ = is_process_running_by_name(resource_name); - - Ok(()) -} - -/// 确保文件未被锁定 -fn ensure_file_not_locked(file_path: &Path) -> Result<()> { - // Windows 下,尝试以独占模式打开文件 - // 如果成功,说明文件未被锁定 - #[cfg(target_os = "windows")] - { - use std::fs::OpenOptions; - use std::io::Read; - - // 尝试以只读模式打开文件 - if file_path.exists() { - let mut file = OpenOptions::new() - .read(true) - .open(file_path) - .with_context(|| format!("无法打开文件(可能被锁定): {}", file_path.display()))?; - - // 尝试读取一个字节来确认文件未被锁定 - let mut buffer = [0u8; 1]; - file.read_exact(&mut buffer) - .with_context(|| format!("文件可能被锁定: {}", file_path.display()))?; - } - } - - #[cfg(not(target_os = "windows"))] - { - // 非 Windows 平台暂不检查 - } - - Ok(()) -} - -/// 直接安装文件(用于 install 模式) -/// -/// # 参数 -/// - `target_path`: 目标路径 -/// - `backup_path`: 备份路径(可选) -/// - `temp_path`: 临时文件路径(已下载并校验通过的文件) -/// -/// # 返回 -/// 安装成功返回 Ok(()), 否则返回错误 -pub fn install_file_direct( - resource_name: &str, - target_path: &Path, - backup_path: Option<&Path>, - temp_path: &Path, -) -> Result<()> { - // 尝试退出同名进程(若存在) - let _ = wait_for_process_exit_by_name(resource_name); - - // 确保目标文件未被锁定 - ensure_file_not_locked(target_path)?; - - replace_file_direct(target_path, backup_path, temp_path)?; - - Ok(()) -} - -/// 备份原文件 -fn backup_file(target_path: &Path, backup_path: &Path) -> Result<()> { - // 确保备份目录存在 - if let Some(parent) = backup_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("创建备份目录失败: {}", parent.display()))?; - } - - // 删除旧的备份文件(如果存在) - if backup_path.exists() { - fs::remove_file(backup_path) - .with_context(|| format!("删除旧备份失败: {}", backup_path.display()))?; - } - - // 复制原文件到备份位置 - fs::copy(target_path, backup_path) - .with_context(|| format!("备份文件失败: {}", backup_path.display()))?; - - Ok(()) -} - -/// 移除只读属性(Windows 平台) -#[cfg(target_os = "windows")] -fn remove_readonly_attribute(file_path: &Path) -> Result<()> { - use std::fs::metadata; - - let metadata = metadata(file_path) - .with_context(|| format!("获取文件元数据失败: {}", file_path.display()))?; - - // 如果文件是只读的,移除只读属性 - if metadata.permissions().readonly() { - let mut perms = metadata.permissions(); - perms.set_readonly(false); - fs::set_permissions(file_path, perms) - .with_context(|| format!("设置文件权限失败: {}", file_path.display()))?; - } - - Ok(()) -} - -#[cfg(not(target_os = "windows"))] -fn remove_readonly_attribute(_file_path: &Path) -> Result<()> { - // 非 Windows 平台无需处理 - Ok(()) -} - -/// 删除旧文件(如果存在) -fn remove_old_file(target_path: &Path) -> Result<()> { - if !target_path.exists() { - return Ok(()); - } - - // 移除只读属性 - remove_readonly_attribute(target_path)?; - - // 删除文件 - fs::remove_file(target_path) - .with_context(|| format!("删除旧文件失败: {}", target_path.display()))?; - - Ok(()) -} - -/// 移动临时文件到目标位置 -fn move_temp_file_to_target(temp_path: &Path, target_path: &Path) -> Result<()> { - if let Err(_) = fs::rename(temp_path, target_path) { - // 如果移动失败(可能跨文件系统),尝试复制 - fs::copy(temp_path, target_path) - .with_context(|| format!("复制文件失败: {}", target_path.display()))?; - - // 删除临时文件 - fs::remove_file(temp_path) - .with_context(|| format!("删除临时文件失败: {}", temp_path.display()))?; - } - - Ok(()) -} - -/// 删除备份文件(如果存在) -fn cleanup_backup_file(backup_path: &Path) { - if backup_path.exists() { - let _ = fs::remove_file(backup_path); - } -} - -/// 直接替换文件(用于 install 模式) -fn replace_file_direct( - target_path: &Path, - backup_path: Option<&Path>, - temp_path: &Path, -) -> Result<()> { - // 1. 备份原文件(如果存在) - let mut created_backup = false; - if target_path.exists() { - if let Some(backup_path) = backup_path { - backup_file(target_path, backup_path)?; - created_backup = true; - } - } - - // 2. 确保目标目录存在 - if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("创建目标目录失败: {}", parent.display()))?; - } - - // 3. 删除旧文件 - remove_old_file(target_path)?; - - // 4. 移动临时文件到目标位置 - move_temp_file_to_target(temp_path, target_path)?; - - // 5. 替换成功后,删除备份文件(仅在替换成功时删除) - if created_backup { - if let Some(backup_path) = backup_path { - cleanup_backup_file(backup_path); - } - } - - Ok(()) -} - -/// 从备份路径回滚(用于 install 模式) -pub fn rollback_from_backup(target_path: &Path, backup_path: &Path) -> Result<()> { - if backup_path.exists() { - // 删除当前文件(如果存在) - if target_path.exists() { - fs::remove_file(target_path).context("删除失败文件失败")?; - } - - // 恢复备份 - fs::copy(backup_path, target_path) - .with_context(|| format!("恢复备份失败: {}", backup_path.display()))?; - - Ok(()) - } else { - Err(anyhow::anyhow!("备份文件不存在,无法回滚")) - } -} diff --git a/src-tauri/src/infrastructure/updater/manifest.rs b/src-tauri/src/infrastructure/updater/manifest.rs deleted file mode 100644 index a760ac2a..00000000 --- a/src-tauri/src/infrastructure/updater/manifest.rs +++ /dev/null @@ -1,81 +0,0 @@ -/// Manifest 处理模块 -/// -/// 负责读写 manifest.json 文件,仅用于展示 -use crate::infrastructure::updater::types::{ArtifactInfo, ClientInfo, Manifest}; -use anyhow::{Context, Result}; -use chrono::Utc; -use std::fs; -use std::path::PathBuf; - -/// 获取 manifest.json 文件路径 -fn get_manifest_path() -> Result { - crate::core::paths::PathManager::get_manifest_file().context("无法获取 manifest.json 路径") -} - -/// 读取 manifest.json -/// -/// # 返回 -/// 返回 Manifest 结构,如果文件不存在则返回默认值 -pub fn read_manifest() -> Result { - let manifest_path = get_manifest_path()?; - - if !manifest_path.exists() { - // 文件不存在,返回默认值 - return Ok(Manifest { - client: ClientInfo { - version: env!("CARGO_PKG_VERSION").to_string(), - }, - artifacts: Vec::new(), - last_updated: Utc::now().to_rfc3339(), - }); - } - - // 读取文件内容 - let content = fs::read_to_string(&manifest_path) - .with_context(|| format!("读取 manifest.json 失败: {}", manifest_path.display()))?; - - // 解析 JSON - let manifest: Manifest = serde_json::from_str(&content) - .with_context(|| format!("解析 manifest.json 失败: {}", manifest_path.display()))?; - - Ok(manifest) -} - -/// 写入 manifest.json -/// -/// # 参数 -/// - `client_version`: 客户端版本 -/// - `artifacts`: 已安装的资源列表 -/// -/// # 说明 -/// manifest.json 仅用于展示,每次更新时都会完全重写 -pub fn write_manifest(client_version: &str, artifacts: &[ArtifactInfo]) -> Result<()> { - let manifest_path = get_manifest_path()?; - - // 构建 manifest 数据 - let artifact_infos: Vec = artifacts.to_vec(); - - let manifest = Manifest { - client: ClientInfo { - version: client_version.to_string(), - }, - artifacts: artifact_infos, - last_updated: Utc::now().to_rfc3339(), - }; - - // 序列化为 JSON - let json_content = - serde_json::to_string_pretty(&manifest).context("序列化 manifest.json 失败")?; - - // 确保目录存在 - if let Some(parent) = manifest_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("创建目录失败: {}", parent.display()))?; - } - - // 写入文件(完全重写) - fs::write(&manifest_path, json_content) - .with_context(|| format!("写入 manifest.json 失败: {}", manifest_path.display()))?; - - Ok(()) -} diff --git a/src-tauri/src/infrastructure/updater/mod.rs b/src-tauri/src/infrastructure/updater/mod.rs deleted file mode 100644 index 39970201..00000000 --- a/src-tauri/src/infrastructure/updater/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -pub mod checker; -pub mod downloader; -pub mod installer; -pub mod manifest; -pub mod planner; -pub mod service; -pub mod state; -/// 更新器模块 -/// -/// 实现独立的更新器逻辑,包括版本检查、下载、校验、替换等功能 -pub mod types; -pub mod verifier; - -pub use checker::*; -pub use downloader::*; -pub use installer::*; -pub use manifest::*; -pub use planner::*; -pub use service::*; -pub use types::*; -pub use verifier::*; diff --git a/src-tauri/src/infrastructure/updater/planner.rs b/src-tauri/src/infrastructure/updater/planner.rs deleted file mode 100644 index 63f915c4..00000000 --- a/src-tauri/src/infrastructure/updater/planner.rs +++ /dev/null @@ -1,131 +0,0 @@ -use crate::app::context::AppContext; -/// 更新计划生成模块 -/// -/// 负责对比本地文件 SHA256 与服务器版本,生成更新任务 -use crate::infrastructure::updater::types::{Artifact, CheckResponse, UpdateTask}; -use anyhow::{Context, Result}; -use sha2::{Digest, Sha256}; -use std::fs::File; -use std::io::Read; -use std::path::{Path, PathBuf}; - -/// 计算文件的完整 SHA256 哈希 -pub fn calculate_file_hash(file_path: &Path) -> Result { - let mut file = - File::open(file_path).with_context(|| format!("无法打开文件: {}", file_path.display()))?; - - let mut hasher = Sha256::new(); - let mut buffer = vec![0; 8192]; // 8KB 缓冲区 - - loop { - let bytes_read = file - .read(&mut buffer) - .with_context(|| format!("读取文件失败: {}", file_path.display()))?; - - if bytes_read == 0 { - break; - } - - hasher.update(&buffer[..bytes_read]); - } - - Ok(format!("{:x}", hasher.finalize())) -} - -/// 获取资源文件的默认路径(当前 exe 同目录) -fn get_resource_path(resource_name: &str) -> Result { - let current_exe = std::env::current_exe().context("无法获取当前可执行文件路径")?; - let exe_dir = current_exe.parent().context("无法获取可执行文件目录")?; - Ok(exe_dir.join(resource_name)) -} - -/// 获取下载临时目录 -/// 优先读取配置 `updater.updater_temp_dir`(相对路径基于统一根目录), -/// 未配置则使用统一根目录下的 `updates` -fn get_temp_dir() -> Result { - let ctx = AppContext::get(); - if let Some(ref custom) = ctx.config.updater.updater_temp_dir { - let p = PathBuf::from(custom); - let root_dir = crate::core::paths::PathManager::get_root_dir()?; - let dir = if p.is_absolute() { p } else { root_dir.join(p) }; - std::fs::create_dir_all(&dir).ok(); - return Ok(dir); - } - - crate::core::paths::PathManager::get_updater_dir() -} - -/// 根据 artifact 获取目标路径 -/// 优先使用 install_path,如果为空则使用当前目录 + resource_name -fn get_target_path(artifact: &Artifact) -> Result { - if let Some(ref install_path) = artifact.install_path { - let path = PathBuf::from(install_path); - - // 规范化路径,防止路径攻击 - let normalized_path = path.canonicalize().or_else(|_| { - if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) { - return Err(anyhow::anyhow!("路径包含非法组件: {}", install_path)); - } - Ok(path) - })?; - - // 使用服务器提供的 install_path - Ok(normalized_path) - } else { - // 使用当前 exe 同级目录 + resource_name - get_resource_path(&artifact.resource_name) - } -} - -/// 检查资源文件是否存在且哈希匹配 -fn check_resource_file(artifact: &Artifact) -> Result { - let target_path = get_target_path(artifact)?; - - // 文件不存在,需要更新 - if !target_path.exists() { - return Ok(true); - } - - // 计算本地文件哈希 - let local_hash = calculate_file_hash(&target_path)?; - let remote_hash = artifact.hash.to_lowercase(); - - // 哈希不匹配,需要更新 - Ok(local_hash.to_lowercase() != remote_hash) -} - -/// 生成更新计划 -/// -/// # 参数 -/// - `check_response`: 服务器检查响应 -/// -/// # 返回 -/// 返回需要更新的任务列表 -pub fn plan_updates(check_response: &CheckResponse) -> Result> { - let mut tasks = Vec::new(); - - // 遍历所有版本类型 - for (_version_type, artifacts) in &check_response.data.versions { - for artifact in artifacts { - // 检查是否需要更新 - let needs_update = check_resource_file(artifact)?; - - if needs_update { - // 获取目标路径(优先使用 install_path,为空则使用当前目录) - let target_path = get_target_path(artifact)?; - let temp_dir = get_temp_dir()?; - let temp_path = temp_dir.join(format!("{}.tmp", artifact.resource_name)); - let backup_path = temp_dir.join(format!("{}.bak", artifact.resource_name)); - - tasks.push(UpdateTask { - artifact: artifact.clone(), - target_path: target_path.clone(), - backup_path: Some(backup_path), - temp_path, - }); - } - } - } - - Ok(tasks) -} diff --git a/src-tauri/src/infrastructure/updater/service.rs b/src-tauri/src/infrastructure/updater/service.rs deleted file mode 100644 index 662e7f13..00000000 --- a/src-tauri/src/infrastructure/updater/service.rs +++ /dev/null @@ -1,194 +0,0 @@ -/// 更新服务模块 -/// -/// 提供更新相关的公共辅助函数,消除代码重复 -use crate::core::config; -use crate::infrastructure::updater::types::{ - ErrorPayload, InstallTask, InstallTasks, UpdateEvent, UpdatePlan, UpdatePlanTask, UpdateTask, -}; -use anyhow::{Context, Result, anyhow}; -use std::fs; -use std::path::PathBuf; -use tauri::{AppHandle, Emitter}; - -/// 锁获取结果 -pub enum LockResult { - Acquired(tokio::sync::MutexGuard<'static, ()>), - Busy, -} - -/// 尝试获取更新检查和下载任务锁(非阻塞) -/// -/// # 返回 -/// - `LockResult::Acquired(guard)` - 成功获取锁 -/// - `LockResult::Busy` - 锁已被占用 -pub async fn acquire_update_lock() -> LockResult { - match crate::infrastructure::updater::state::try_acquire_update_check_and_download_lock().await - { - Some(guard) => LockResult::Acquired(guard), - None => LockResult::Busy, - } -} - -/// 初始化更新器配置 -pub fn init_updater_config() -> crate::core::error::Result<()> { - // 检查配置是否已初始化,如果已初始化则直接返回 - if config::get().is_some() { - return Ok(()); - } - // 从嵌入的加密配置中加载并初始化配置 - config::init() -} - -/// 获取可执行文件目录 -/// -/// # 返回 -/// 返回可执行文件所在目录的 PathBuf -pub fn get_exe_directory() -> Result { - let current_exe = std::env::current_exe().with_context(|| "获取当前可执行文件路径失败")?; - current_exe - .parent() - .ok_or_else(|| anyhow::anyhow!("无法获取可执行文件目录")) - .map(|p| p.to_path_buf()) -} - -/// 获取安装任务文件路径 -/// -/// # 返回 -/// 返回安装任务文件的完整路径 -pub fn get_tasks_file_path() -> Result { - crate::core::paths::PathManager::get_update_tasks_file() -} - -/// 发送更新事件 -/// -/// # 参数 -/// - `app_handle`: Tauri 应用句柄 -/// - `event`: 更新事件 -pub fn emit_event(app_handle: &AppHandle, event: UpdateEvent) { - let _ = app_handle.emit(event.event_type(), event); -} - -/// 发送错误事件 -/// -/// # 参数 -/// - `app_handle`: Tauri 应用句柄 -/// - `code`: 错误代码 -/// - `error_message`: 错误消息 -pub fn emit_error_event(app_handle: &AppHandle, code: i32, error_message: String) { - let error_event = UpdateEvent::CheckFailed { - code, - payload: ErrorPayload { error_message }, - }; - emit_event(app_handle, error_event); -} - -/// 将 UpdateTask 转换为 UpdatePlanTask -/// -/// # 参数 -/// - `tasks`: UpdateTask 列表 -/// -/// # 返回 -/// 返回 UpdatePlanTask 列表 -pub fn update_tasks_to_plan_tasks(tasks: &[UpdateTask]) -> Vec { - tasks - .iter() - .map(|task| UpdatePlanTask { - artifact: task.artifact.clone(), - target_path: task.target_path.to_string_lossy().to_string(), - backup_path: task.backup_path.as_ref().map(|p| p.to_string_lossy().to_string()), - temp_path: task.temp_path.to_string_lossy().to_string(), - }) - .collect() -} - -/// 将 UpdatePlanTask 转换为 UpdateTask -/// -/// # 参数 -/// - `plan_tasks`: UpdatePlanTask 列表 -/// -/// # 返回 -/// 返回 UpdateTask 列表 -pub fn plan_tasks_to_update_tasks(plan_tasks: &[UpdatePlanTask]) -> Vec { - plan_tasks - .iter() - .map(|plan_task| UpdateTask { - artifact: plan_task.artifact.clone(), - target_path: PathBuf::from(&plan_task.target_path), - backup_path: plan_task.backup_path.as_ref().map(|p| PathBuf::from(p)), - temp_path: PathBuf::from(&plan_task.temp_path), - }) - .collect() -} - -/// 将 UpdateTask 转换为 InstallTask -/// -/// # 参数 -/// - `task`: UpdateTask -/// -/// # 返回 -/// 返回 InstallTask -pub fn update_task_to_install_task(task: &UpdateTask) -> InstallTask { - InstallTask { - resource_name: task.artifact.resource_name.clone(), - version: task.artifact.version.clone(), - target_path: task.target_path.to_string_lossy().to_string(), - backup_path: task.backup_path.as_ref().map(|p| p.to_string_lossy().to_string()), - temp_path: task.temp_path.to_string_lossy().to_string(), - expected_hash: task.artifact.hash.clone(), - } -} - -/// 缓存更新计划 -pub async fn store_update_plan(tasks: &[UpdateTask]) -> Result<()> { - let plan_tasks = update_tasks_to_plan_tasks(tasks); - let update_plan = UpdatePlan { tasks: plan_tasks }; - crate::infrastructure::updater::state::store_update_plan(update_plan).await; - Ok(()) -} - -/// 取出更新计划(内存缓存) -pub async fn take_update_plan() -> Result { - crate::infrastructure::updater::state::take_update_plan() - .await - .ok_or_else(|| anyhow!("更新计划不存在,请重新执行检查")) -} - -pub async fn store_installer_package_path(path: String) { - crate::infrastructure::updater::state::store_installer_package_path(path).await; -} - -pub async fn take_installer_package_path() -> Option { - crate::infrastructure::updater::state::take_installer_package_path().await -} - -pub async fn clear_installer_package_path() { - crate::infrastructure::updater::state::clear_installer_package_path().await; -} - -/// 保存安装任务到文件 -/// -/// # 参数 -/// - `install_tasks`: InstallTask 列表 -/// -/// # 返回 -/// 返回保存的文件路径 -pub fn save_install_tasks(install_tasks: &[InstallTask]) -> Result { - let install_tasks_data = InstallTasks { - tasks: install_tasks.to_vec(), - }; - - let tasks_json = - serde_json::to_string_pretty(&install_tasks_data).with_context(|| "序列化安装任务失败")?; - - let file_path = get_tasks_file_path()?; - - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("创建安装任务目录失败: {}", parent.display()))?; - } - - fs::write(&file_path, tasks_json) - .with_context(|| format!("写入安装任务文件失败: {}", file_path.display()))?; - - Ok(file_path) -} diff --git a/src-tauri/src/infrastructure/updater/state.rs b/src-tauri/src/infrastructure/updater/state.rs deleted file mode 100644 index 2938d52f..00000000 --- a/src-tauri/src/infrastructure/updater/state.rs +++ /dev/null @@ -1,59 +0,0 @@ -/// 状态管理模块 -/// -/// 提供更新检查和下载任务的并发控制锁 -use once_cell::sync::Lazy; -use std::sync::Arc; -use tokio::sync::Mutex as TokioMutex; - -use crate::infrastructure::updater::types::UpdatePlan; - -/// 更新检查和下载任务互斥锁:确保同一时间只有一个检查/下载任务在执行 -static UPDATE_CHECK_AND_DOWNLOAD_LOCK: Lazy>> = - Lazy::new(|| Arc::new(TokioMutex::new(()))); - -/// 内存中的更新计划缓存 -static UPDATE_PLAN_CACHE: Lazy>> = - Lazy::new(|| TokioMutex::new(None)); - -/// 安装包升级路径缓存 -static INSTALLER_PACKAGE_PATH_CACHE: Lazy>> = - Lazy::new(|| TokioMutex::new(None)); - -/// 尝试获取更新检查和下载任务锁(非阻塞) -/// -/// # 返回 -/// 如果成功获取锁,返回 `Some(guard)`,否则返回 `None` -/// -/// # 说明 -/// 使用此锁可以防止并发执行检查更新和下载任务 -pub async fn try_acquire_update_check_and_download_lock() --> Option> { - UPDATE_CHECK_AND_DOWNLOAD_LOCK.try_lock().ok() -} - -/// 将更新计划写入缓存,覆盖旧数据 -pub async fn store_update_plan(plan: UpdatePlan) { - let mut guard = UPDATE_PLAN_CACHE.lock().await; - *guard = Some(plan); -} - -/// 从缓存中取出更新计划(取出后即清空) -pub async fn take_update_plan() -> Option { - let mut guard = UPDATE_PLAN_CACHE.lock().await; - guard.take() -} - -pub async fn store_installer_package_path(path: String) { - let mut guard = INSTALLER_PACKAGE_PATH_CACHE.lock().await; - *guard = Some(path); -} - -pub async fn take_installer_package_path() -> Option { - let mut guard = INSTALLER_PACKAGE_PATH_CACHE.lock().await; - guard.take() -} - -pub async fn clear_installer_package_path() { - let mut guard = INSTALLER_PACKAGE_PATH_CACHE.lock().await; - *guard = None; -} diff --git a/src-tauri/src/infrastructure/updater/types.rs b/src-tauri/src/infrastructure/updater/types.rs deleted file mode 100644 index 131d80b1..00000000 --- a/src-tauri/src/infrastructure/updater/types.rs +++ /dev/null @@ -1,346 +0,0 @@ -/// 更新器数据模型定义 -use serde::{Deserialize, Deserializer, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; - -/// 反序列化 id 字段,支持整数或字符串(字符串转换为整数) -fn deserialize_id<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - use serde::de::Error; - - let value: serde_json::Value = Deserialize::deserialize(deserializer)?; - - match value { - serde_json::Value::Null => Ok(None), - serde_json::Value::Number(n) => { - n.as_i64().ok_or_else(|| D::Error::custom("无法将数字转换为 i64")).map(Some) - } - serde_json::Value::String(s) => s - .parse::() - .map_err(|_| D::Error::custom("无法将字符串解析为整数")) - .map(Some), - _ => Err(D::Error::custom("id 必须是数字或字符串")), - } -} - -/// 反序列化 file_size 字段,支持 Option 到 u64 的转换 -fn deserialize_file_size<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - use serde::de::Error; - - let value: Option = Option::deserialize(deserializer)?; - match value { - None => Ok(0), - Some(size) => { - if size < 0 { - Err(D::Error::custom("file_size 不能为负数")) - } else { - Ok(size as u64) - } - } - } -} - -/// 服务器返回的 Artifact 结构 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Artifact { - #[serde(deserialize_with = "deserialize_id")] - pub id: Option, - #[serde(default)] - pub name: String, - pub version: String, - #[serde(default)] - pub url: String, - #[serde(default)] - pub hash: String, // SHA256 - #[serde(deserialize_with = "deserialize_file_size")] - pub file_size: u64, - #[serde(default = "default_platform")] - pub platform: String, - pub resource_name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub signature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub notes: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub is_latest: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub install_path: Option, -} - -/// 默认平台值 -fn default_platform() -> String { - "windows".to_string() -} - -/// 服务器返回的版本集合 -/// -/// 使用 HashMap 以支持灵活的字段名(T9_CLIENT, DLL, OTHER_PROGRAM, T9_CLIENT_INSTALLER 等) -pub type Versions = HashMap>; - -/// 服务器返回的检查响应数据 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CheckResponseData { - pub versions: Versions, -} - -/// 服务器返回的完整检查响应 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CheckResponse { - pub code: i32, - pub message: String, - pub data: CheckResponseData, -} - -/// 检查请求 -#[derive(Debug, Clone, Serialize)] -pub struct CheckRequest {} - -/// 资源类型 -/// 更新任务 -#[derive(Debug, Clone)] -pub struct UpdateTask { - pub artifact: Artifact, - pub target_path: PathBuf, - pub backup_path: Option, - pub temp_path: PathBuf, // 下载后的临时路径 -} - -/// 序列化的安装任务(用于客户端和更新器之间传递) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InstallTask { - pub resource_name: String, - pub version: String, - pub target_path: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub backup_path: Option, - pub temp_path: String, // 已下载并校验通过的文件路径 - pub expected_hash: String, // SHA256,用于再次确认 -} - -/// 安装任务列表(用于传递多个任务) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InstallTasks { - pub tasks: Vec, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum InstallStrategy { - DirectReplace, - InstallerPackage, -} - -/// 更新计划(用于检查后保存,供下载使用) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdatePlan { - pub tasks: Vec, -} - -/// 更新计划任务(序列化版本,用于保存到文件) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdatePlanTask { - pub artifact: Artifact, - pub target_path: String, - pub backup_path: Option, - pub temp_path: String, -} - -/// 更新状态 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum UpdaterState { - /// 空闲,未检查或已检查但无更新 - Idle, - /// 有可用更新 - Available, - /// 检查中 - Checking, - /// 下载中 - Downloading, - /// 校验中 - Verifying, - /// 安装中 - Installing, - /// 完成 - Success, - /// 部分失败(部分资源更新成功) - PartialFailed, - /// 失败 - Failed, -} - -/// 更新状态详情 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdaterStateInfo { - pub state: UpdaterState, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tasks: Option>, -} - -/// 任务信息(用于状态返回) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TaskInfo { - pub resource_name: String, - pub version: String, - pub status: String, // "pending", "downloading", "verifying", "installing", "success", "failed" - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// Manifest 文件中存储的版本信息 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Manifest { - pub client: ClientInfo, - pub artifacts: Vec, - pub last_updated: String, // ISO 8601 时间戳 -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LatestRelease { - pub version: String, - #[serde(default)] - pub notes: String, - pub pub_date: String, - pub platforms: HashMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LatestReleasePlatform { - #[serde(default)] - pub url: String, - #[serde(default)] - pub r2_url: String, -} - -/// 客户端信息 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClientInfo { - pub version: String, -} - -/// Manifest 中的资源信息(仅展示用) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ArtifactInfo { - pub resource_name: String, - pub version: String, -} - -/// 更新事件类型 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum UpdateEvent { - /// 检查中 - Checking { payload: CheckingPayload }, - /// 检查失败 - CheckFailed { code: i32, payload: ErrorPayload }, - /// 计划失败 - PlanFailed { code: i32, payload: ErrorPayload }, - /// 无需更新 - NoUpdates { payload: NoUpdatesPayload }, - /// 发现更新 - FoundUpdates { payload: FoundUpdatesPayload }, - /// 下载中 - Downloading { payload: DownloadingPayload }, - /// 下载完成 - DownloadComplete { payload: DownloadCompletePayload }, - /// 部分下载完成 - DownloadPartial { payload: DownloadPartialPayload }, - /// 下载失败 - DownloadFailed { code: i32, payload: ErrorPayload }, - /// 下载进度 - DownloadProgress { payload: DownloadProgressPayload }, -} - -impl UpdateEvent { - /// 获取事件类型字符串 - /// - /// 返回格式:`update__` - /// 例如:`update_checking`, `update_check_failed`, `update_download_complete` - pub fn event_type(&self) -> &'static str { - match self { - UpdateEvent::Checking { .. } => "update_checking", - UpdateEvent::CheckFailed { .. } => "update_check_failed", - UpdateEvent::PlanFailed { .. } => "update_plan_failed", - UpdateEvent::NoUpdates { .. } => "update_no_updates", - UpdateEvent::FoundUpdates { .. } => "update_found_updates", - UpdateEvent::Downloading { .. } => "update_downloading", - UpdateEvent::DownloadComplete { .. } => "update_download_complete", - UpdateEvent::DownloadPartial { .. } => "update_download_partial", - UpdateEvent::DownloadFailed { .. } => "update_download_failed", - UpdateEvent::DownloadProgress { .. } => "update_download_progress", - } - } -} - -/// 检查中事件负载 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CheckingPayload { - // 检查阶段无额外数据 -} - -/// 错误事件负载 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ErrorPayload { - /// 错误消息 - pub error_message: String, -} - -/// 无需更新事件负载 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct NoUpdatesPayload { - // 无需更新无额外数据 -} - -/// 发现更新事件负载 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct FoundUpdatesPayload { - /// 更新数量 - pub update_count: usize, -} - -/// 下载中事件负载 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct DownloadingPayload { - /// 更新数量 - pub update_count: usize, -} - -/// 下载完成事件负载 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct DownloadCompletePayload { - /// 更新任务文件路径 - pub tasks_file: String, - /// 成功下载的文件数量 - pub success_count: usize, -} - -/// 部分下载完成事件负载 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct DownloadPartialPayload { - /// 更新任务文件路径 - pub tasks_file: String, - /// 成功下载的文件数量 - pub success_count: usize, - /// 失败的文件数量 - pub failed_count: usize, - /// 错误消息 - pub error_message: String, -} - -/// 下载进度事件负载 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct DownloadProgressPayload { - /// 当前已下载总大小(字节) - pub downloaded: u64, - /// 总大小(字节) - pub total: u64, - /// 进度百分比(0-100) - pub percentage: f64, -} diff --git a/src-tauri/src/infrastructure/updater/verifier.rs b/src-tauri/src/infrastructure/updater/verifier.rs deleted file mode 100644 index 43d76236..00000000 --- a/src-tauri/src/infrastructure/updater/verifier.rs +++ /dev/null @@ -1,41 +0,0 @@ -/// 校验模块 -/// -/// 负责对下载的文件进行 SHA256 校验 -use crate::infrastructure::updater::types::UpdateTask; -use anyhow::{Context, Result}; -use std::path::Path; - -// 重新导出 calculate_file_hash 函数 -pub use crate::infrastructure::updater::planner::calculate_file_hash; - -/// 内部使用的哈希计算函数 -fn calculate_file_hash_internal(file_path: &Path) -> Result { - crate::infrastructure::updater::planner::calculate_file_hash(file_path) -} - -/// 校验下载文件的 SHA256 哈希 -/// -/// # 参数 -/// - `task`: 更新任务 -/// -/// # 返回 -/// 如果哈希匹配返回 Ok(()), 否则返回错误 -pub fn verify_file_hash(task: &UpdateTask) -> Result<()> { - // 计算临时文件的哈希 - let actual_hash = calculate_file_hash_internal(&task.temp_path) - .with_context(|| format!("计算文件哈希失败: {}", task.temp_path.display()))?; - - // 转换为小写进行比较 - let expected_hash = task.artifact.hash.to_lowercase(); - let actual_hash_lower = actual_hash.to_lowercase(); - - if actual_hash_lower != expected_hash { - return Err(anyhow::anyhow!( - "文件哈希不匹配: 期望 {}, 实际 {}", - expected_hash, - actual_hash - )); - } - - Ok(()) -} diff --git a/src-tauri/src/local_api/client/business.rs b/src-tauri/src/local_api/client/business.rs new file mode 100644 index 00000000..096a7872 --- /dev/null +++ b/src-tauri/src/local_api/client/business.rs @@ -0,0 +1,92 @@ +use axum::http::StatusCode; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use tauri::Manager; + +use crate::app::handle::get_app_handle; + +pub async fn dispatch_request( + route: &str, + permission_code: &str, + api_key: &str, + payload: Value, +) -> Result { + let app = + get_app_handle().map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?; + let business_context = app.state::(); + let validation = business::services::local_api::validate_local_api_key_service( + &business_context, + &business::entitys::ValidateLocalApiKeyRequest { + api_key: api_key.to_string(), + permission_code: permission_code.to_string(), + }, + ) + .await + .map_err(|message| (StatusCode::UNAUTHORIZED, message))?; + let request_context = business_context.for_user(validation.user_uuid); + + if let Some(result) = + business::dispatcher::dispatch_post(&request_context, route, &payload).await + { + return match result { + Ok(data) => Ok(json!({ "code": 1, "message": "OK", "data": data })), + Err(message) => Err((StatusCode::BAD_REQUEST, message)), + }; + } + + Err(( + StatusCode::NOT_FOUND, + format!("Local business route is not available: {route}"), + )) +} + +pub async fn dispatch_data_request( + route: &str, + permission_code: &str, + api_key: &str, + payload: Value, +) -> Result +where + T: DeserializeOwned, +{ + let value = dispatch_request(route, permission_code, api_key, payload).await?; + let response: crate::infrastructure::http::client::JsonRespnse = serde_json::from_value(value) + .map_err(|error| { + ( + StatusCode::BAD_GATEWAY, + format!("failed to parse local response: {error}"), + ) + })?; + + let data = response.data.ok_or_else(|| { + ( + StatusCode::BAD_GATEWAY, + "missing local response data".to_string(), + ) + })?; + + serde_json::from_value(data).map_err(|error| { + ( + StatusCode::BAD_GATEWAY, + format!("failed to parse local response data: {error}"), + ) + }) +} + +pub async fn dispatch_data_value_request( + route: &str, + permission_code: &str, + api_key: &str, + payload: Value, +) -> Result +where + T: DeserializeOwned + Serialize, +{ + let data: T = dispatch_data_request(route, permission_code, api_key, payload).await?; + serde_json::to_value(data).map_err(|error| { + ( + StatusCode::BAD_GATEWAY, + format!("failed to serialize local response data: {error}"), + ) + }) +} diff --git a/src-tauri/src/local_api/client/main_server.rs b/src-tauri/src/local_api/client/main_server.rs deleted file mode 100644 index 98c9bde2..00000000 --- a/src-tauri/src/local_api/client/main_server.rs +++ /dev/null @@ -1,102 +0,0 @@ -use axum::http::StatusCode; -use serde::{Serialize, de::DeserializeOwned}; -use serde_json::{Value, json}; - -use crate::{app::context::AppContext, infrastructure::main_server::error::ResponseError}; - -use super::headers::build_local_api_auth_headers; - -pub async fn proxy_request( - server_path: &str, - permission_code: &str, - api_key: &str, - payload: Value, -) -> Result { - let ctx = AppContext::get(); - let headers = build_local_api_auth_headers(api_key, permission_code) - .map_err(|error| (StatusCode::BAD_REQUEST, error))?; - - let response = ctx - .main_server_client - .post_with_headers(server_path, &payload, headers) - .await - .map_err(map_main_server_error)?; - - serde_json::to_value(response).map_err(|_| { - ( - StatusCode::BAD_GATEWAY, - json!({ - "code": -1, - "message": "failed to serialize response" - }) - .to_string(), - ) - }) -} - -pub async fn proxy_data_request( - server_path: &str, - permission_code: &str, - api_key: &str, - payload: Value, -) -> Result -where - T: DeserializeOwned, -{ - let value = proxy_request(server_path, permission_code, api_key, payload).await?; - let response: crate::infrastructure::http::client::JsonRespnse = serde_json::from_value(value) - .map_err(|error| { - ( - StatusCode::BAD_GATEWAY, - format!("failed to parse server response: {error}"), - ) - })?; - - let data = response.data.ok_or_else(|| { - ( - StatusCode::BAD_GATEWAY, - "missing server response data".to_string(), - ) - })?; - - serde_json::from_value(data).map_err(|error| { - ( - StatusCode::BAD_GATEWAY, - format!("failed to parse response data: {error}"), - ) - }) -} - -pub async fn proxy_data_value_request( - server_path: &str, - permission_code: &str, - api_key: &str, - payload: Value, -) -> Result -where - T: DeserializeOwned + Serialize, -{ - let data: T = proxy_data_request(server_path, permission_code, api_key, payload).await?; - serde_json::to_value(data).map_err(|error| { - ( - StatusCode::BAD_GATEWAY, - format!("failed to serialize response data: {error}"), - ) - }) -} - -fn map_main_server_error(error: anyhow::Error) -> (StatusCode, String) { - if let Some(response_error) = error.downcast_ref::() { - return match response_error { - ResponseError::Unauthorized { message } => (StatusCode::UNAUTHORIZED, message.clone()), - ResponseError::BadRequest { message } => (StatusCode::BAD_REQUEST, message.clone()), - ResponseError::PublicKeyExpired => ( - StatusCode::BAD_GATEWAY, - "server public key expired".to_string(), - ), - other => (StatusCode::BAD_GATEWAY, other.to_string()), - }; - } - - (StatusCode::BAD_GATEWAY, error.to_string()) -} diff --git a/src-tauri/src/local_api/client/mod.rs b/src-tauri/src/local_api/client/mod.rs index 055f132e..5da5bb59 100644 --- a/src-tauri/src/local_api/client/mod.rs +++ b/src-tauri/src/local_api/client/mod.rs @@ -1,2 +1,2 @@ +pub mod business; pub mod headers; -pub mod main_server; diff --git a/src-tauri/src/local_api/entitys/browser_kernels.rs b/src-tauri/src/local_api/entitys/browser_kernels.rs index 0fb52111..da79cb8e 100644 --- a/src-tauri/src/local_api/entitys/browser_kernels.rs +++ b/src-tauri/src/local_api/entitys/browser_kernels.rs @@ -4,17 +4,25 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct LocalApiBrowserKernelVersion { + pub kernel_id: String, + pub type_code: String, pub resource_name: String, pub version: String, pub name: Option, pub notes: Option, pub platform: Option, + pub url: Option, + pub hash: String, + pub signature: String, pub file_size: Option, pub is_latest: bool, pub status: String, pub arch: Option, pub package_format: Option, pub requires_extract: bool, + pub entrypoint_template: Option, + pub extract_root: Option, + pub installed: bool, } pub type LocalApiBrowserKernelListResponse = HashMap>; diff --git a/src-tauri/src/local_api/manager/runtime.rs b/src-tauri/src/local_api/manager/runtime.rs index 3c30a00f..34f0b2b5 100644 --- a/src-tauri/src/local_api/manager/runtime.rs +++ b/src-tauri/src/local_api/manager/runtime.rs @@ -4,10 +4,9 @@ use std::sync::{ }; use anyhow::Result; -use serde_json::json; use tokio::sync::Mutex; -use crate::{app::context::AppContext, local_api::types::LocalApiRuntimeConfig}; +use crate::local_api::types::LocalApiRuntimeConfig; use super::super::server::bootstrap::{LocalApiServerHandle, spawn_local_api_server}; @@ -24,22 +23,17 @@ impl LocalApiManager { } } - pub async fn refresh_from_server(self: &Arc) -> Result<()> { - let ctx = AppContext::get(); - let response = match ctx.main_server_client.post("local-api/get", &json!({})).await { - Ok(response) => response, - Err(error) => { - log::warn!("failed to fetch local api config: {}", error); - return Ok(()); - } - }; - - let config = response - .data - .ok_or_else(|| anyhow::anyhow!("missing local api config data")) - .and_then(|data| { - serde_json::from_value::(data).map_err(Into::into) - })?; + pub async fn refresh(self: &Arc, context: &business::svc_ctx::SvcCtx) -> Result<()> { + let config = business::services::local_api::get_local_api_config_service( + context, + context.local_user_uuid, + ) + .await + .map_err(anyhow::Error::msg) + .and_then(|config| serde_json::to_value(config).map_err(Into::into)) + .and_then(|value| { + serde_json::from_value::(value).map_err(Into::into) + })?; if !config.enabled { self.stop().await; diff --git a/src-tauri/src/local_api/middleware/auth.rs b/src-tauri/src/local_api/middleware/auth.rs index 8b4ab5cc..bb0313ec 100644 --- a/src-tauri/src/local_api/middleware/auth.rs +++ b/src-tauri/src/local_api/middleware/auth.rs @@ -14,7 +14,7 @@ use crate::local_api::{ use super::super::client::headers::extract_api_key; pub async fn auth_middleware( - State(_state): State, + State(state): State, mut request: Request, next: Next, ) -> Response { @@ -30,6 +30,14 @@ pub async fn auth_middleware( .into_response(); }; + if api_key != state.config.api_key { + return LocalApiResponse::<()>::fail( + Some("invalid api key"), + axum::http::StatusCode::UNAUTHORIZED, + ) + .into_response(); + } + request.extensions_mut().insert(LocalApiRequestContext { api_key }); next.run(request).await } diff --git a/src-tauri/src/local_api/services/browser_kernels.rs b/src-tauri/src/local_api/services/browser_kernels.rs index a075ea8f..ea820c93 100644 --- a/src-tauri/src/local_api/services/browser_kernels.rs +++ b/src-tauri/src/local_api/services/browser_kernels.rs @@ -2,14 +2,14 @@ use axum::http::StatusCode; use serde_json::Value; use crate::local_api::{ - client::main_server::proxy_data_value_request, context::LocalApiRequestContext, + client::business::dispatch_data_value_request, context::LocalApiRequestContext, }; pub async fn list_browser_kernels_service( ctx: &LocalApiRequestContext, payload: Value, ) -> Result { - proxy_data_value_request::( + dispatch_data_value_request::( "browser-kernels/list", "browser-kernels.list", &ctx.api_key, diff --git a/src-tauri/src/local_api/services/environments.rs b/src-tauri/src/local_api/services/environments.rs index ded66622..d13b9eaf 100644 --- a/src-tauri/src/local_api/services/environments.rs +++ b/src-tauri/src/local_api/services/environments.rs @@ -4,7 +4,7 @@ use serde_json::Value; use crate::{ app::handle::get_app_handle, local_api::{ - client::main_server::proxy_data_value_request, + client::business::dispatch_data_value_request, context::LocalApiRequestContext, entitys::{ LocalApiBatchStartEnvironmentsRequest, LocalApiEnvironmentActionResponse, @@ -22,7 +22,7 @@ macro_rules! environment_service { ctx: &LocalApiRequestContext, payload: Value, ) -> Result { - proxy_data_value_request::<$response_ty>( + dispatch_data_value_request::<$response_ty>( concat!("environments", $path), $permission, &ctx.api_key, diff --git a/src-tauri/src/local_api/services/groups.rs b/src-tauri/src/local_api/services/groups.rs index e32b6772..c0955d80 100644 --- a/src-tauri/src/local_api/services/groups.rs +++ b/src-tauri/src/local_api/services/groups.rs @@ -2,25 +2,11 @@ use axum::http::StatusCode; use serde_json::Value; use crate::local_api::{ - client::main_server::proxy_data_value_request, context::LocalApiRequestContext, + client::business::dispatch_data_request, context::LocalApiRequestContext, services::forward_service, types::LocalApiRoute, }; macro_rules! group_service { - ($fn_name:ident, $path:literal, $permission:literal, $response_ty:ty) => { - pub async fn $fn_name( - ctx: &LocalApiRequestContext, - payload: Value, - ) -> Result { - proxy_data_value_request::<$response_ty>( - concat!("groups", $path), - $permission, - &ctx.api_key, - payload, - ) - .await - } - }; ($fn_name:ident, $path:literal, $permission:literal) => { pub async fn $fn_name( ctx: &LocalApiRequestContext, @@ -41,12 +27,15 @@ macro_rules! group_service { }; } -group_service!( - list_groups_service, - "/list", - "groups.list", - crate::local_api::entitys::LocalApiGroupListResponse -); +pub async fn list_groups_service( + ctx: &LocalApiRequestContext, + payload: Value, +) -> Result { + let items = + dispatch_data_request::>("groups/list", "groups.list", &ctx.api_key, payload) + .await?; + Ok(serde_json::json!({ "items": items })) +} group_service!(create_group_service, "/create", "groups.create"); group_service!(update_group_service, "/update", "groups.update"); group_service!(delete_group_service, "/delete", "groups.delete"); diff --git a/src-tauri/src/local_api/services/mod.rs b/src-tauri/src/local_api/services/mod.rs index 3825c0d3..1f4685c5 100644 --- a/src-tauri/src/local_api/services/mod.rs +++ b/src-tauri/src/local_api/services/mod.rs @@ -9,7 +9,7 @@ use axum::http::StatusCode; use serde_json::Value; use crate::local_api::{ - client::main_server::proxy_request, context::LocalApiRequestContext, types::LocalApiRoute, + client::business::dispatch_request, context::LocalApiRequestContext, types::LocalApiRoute, }; pub async fn forward_service( @@ -17,11 +17,19 @@ pub async fn forward_service( payload: Value, route: LocalApiRoute, ) -> Result { - proxy_request( + let response = dispatch_request( route.server_path, route.permission_code, &ctx.api_key, payload, ) - .await + .await?; + let response: crate::infrastructure::http::client::JsonRespnse = + serde_json::from_value(response).map_err(|error| { + ( + StatusCode::BAD_GATEWAY, + format!("failed to parse business response: {error}"), + ) + })?; + Ok(response.data.unwrap_or(Value::Null)) } diff --git a/src-tauri/src/local_api/services/proxies.rs b/src-tauri/src/local_api/services/proxies.rs index b92e563b..fd1d98a0 100644 --- a/src-tauri/src/local_api/services/proxies.rs +++ b/src-tauri/src/local_api/services/proxies.rs @@ -2,7 +2,7 @@ use axum::http::StatusCode; use serde_json::Value; use crate::local_api::{ - client::main_server::proxy_data_value_request, context::LocalApiRequestContext, + client::business::dispatch_data_value_request, context::LocalApiRequestContext, services::forward_service, types::LocalApiRoute, }; @@ -12,7 +12,7 @@ macro_rules! proxy_service { ctx: &LocalApiRequestContext, payload: Value, ) -> Result { - proxy_data_value_request::<$response_ty>( + dispatch_data_value_request::<$response_ty>( concat!("proxies", $path), $permission, &ctx.api_key, diff --git a/src-tauri/src/local_api/services/tags.rs b/src-tauri/src/local_api/services/tags.rs index c2866ff9..bdff4ab3 100644 --- a/src-tauri/src/local_api/services/tags.rs +++ b/src-tauri/src/local_api/services/tags.rs @@ -2,7 +2,7 @@ use axum::http::StatusCode; use serde_json::Value; use crate::local_api::{ - client::main_server::proxy_data_value_request, context::LocalApiRequestContext, + client::business::dispatch_data_value_request, context::LocalApiRequestContext, services::forward_service, types::LocalApiRoute, }; @@ -12,7 +12,7 @@ macro_rules! tag_service { ctx: &LocalApiRequestContext, payload: Value, ) -> Result { - proxy_data_value_request::<$response_ty>( + dispatch_data_value_request::<$response_ty>( concat!("tags", $path), $permission, &ctx.api_key, diff --git a/src-tauri/src/local_api/services/workspaces.rs b/src-tauri/src/local_api/services/workspaces.rs index cb921f52..62b4b913 100644 --- a/src-tauri/src/local_api/services/workspaces.rs +++ b/src-tauri/src/local_api/services/workspaces.rs @@ -2,7 +2,7 @@ use axum::http::StatusCode; use serde_json::Value; use crate::local_api::{ - client::main_server::proxy_data_value_request, context::LocalApiRequestContext, + client::business::dispatch_data_value_request, context::LocalApiRequestContext, services::forward_service, types::LocalApiRoute, }; @@ -12,7 +12,7 @@ macro_rules! workspace_service { ctx: &LocalApiRequestContext, payload: Value, ) -> Result { - proxy_data_value_request::<$response_ty>( + dispatch_data_value_request::<$response_ty>( concat!("workspaces", $path), $permission, &ctx.api_key, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 51af27d7..25a96f96 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,9 +3,7 @@ use log::error; -use simprint_lib::core::config; use simprint_lib::core::logger; -use simprint_lib::infrastructure::deeplink; /// 屏蔽环境变量 pub fn disable_env_var() { @@ -22,24 +20,13 @@ async fn main() -> Result<(), Box> { disable_env_var(); - // 1. 首先初始化配置(从加密的 bin 中解密得到配置内容) - // 注意:必须在 security_context::init() 之前初始化,因为 anchor 存储需要读取配置 - if let Err(e) = config::init() { - eprintln!("Failed to initialize config: {}", e); - std::process::exit(-1); - } - - // 1.1 处理"应用未启动时"的 deep link 冷启动参数(协议拉起会把 URL 作为 argv 传入) - // 例如:simprint://register?referral_code=XXXX - deeplink::process_first_url_arg(std::env::args()); - // 初始化日志系统(兜底目录,Tauri 启动后会在 setup 中按 store 设置重新初始化) logger::init_logging(logger::bootstrap_log_dir()); // 记录应用启动 logger::log_app_start(); - // 初始化应用核心组件(AppContext、RSA 密钥对等) + // 初始化应用核心组件 if let Err(e) = simprint_lib::app::lifecycle::init_early() { error!("Failed to initialize app context: {}", e); std::process::exit(-1); diff --git a/src-tauri/src/mcp/bridge.rs b/src-tauri/src/mcp/bridge.rs index 017a38c6..334e3384 100644 --- a/src-tauri/src/mcp/bridge.rs +++ b/src-tauri/src/mcp/bridge.rs @@ -6,7 +6,7 @@ use crate::{ app::handle::get_app_handle, domain::environment::EnvironmentStatus, local_api::{ - client::main_server::proxy_data_request, + client::business::dispatch_data_request, entitys::{ LocalApiBrowserKernelListResponse, LocalApiEnvironmentActionResponse, LocalApiEnvironmentDetailResponse, LocalApiEnvironmentListResponse, @@ -84,14 +84,14 @@ impl LocalApiBridge { &self, request: LocalApiListEnvironmentsRequest, ) -> Result { - self.proxy_main_server("environments/list", "environments.list", &request).await + self.dispatch_business("environments/list", "environments.list", &request).await } pub async fn get_environment( &self, env_uuid: &str, ) -> Result { - self.proxy_main_server( + self.dispatch_business( "environments/detail", "environments.detail", &json!({ "uuid": env_uuid }), @@ -182,14 +182,14 @@ impl LocalApiBridge { } pub async fn list_groups(&self) -> Result { - self.proxy_main_server("groups/list", "groups.list", &json!({})).await + self.dispatch_business("groups/list", "groups.list", &json!({})).await } pub async fn create_group( &self, payload: &T, ) -> Result { - self.proxy_main_server("groups/create", "groups.create", payload).await + self.dispatch_business("groups/create", "groups.create", payload).await } pub async fn update_group( @@ -197,13 +197,13 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = - self.proxy_main_server("groups/update", "groups.update", payload).await?; + self.dispatch_business("groups/update", "groups.update", payload).await?; Ok(()) } pub async fn delete_group(&self, group_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "groups/delete", "groups.delete", &json!({ "uuid": group_uuid }), @@ -213,38 +213,38 @@ impl LocalApiBridge { } pub async fn list_tags(&self) -> Result, McpToolError> { - self.proxy_main_server("tags/list", "tags.list", &json!({})).await + self.dispatch_business("tags/list", "tags.list", &json!({})).await } pub async fn create_tag( &self, payload: &T, ) -> Result { - self.proxy_main_server("tags/create", "tags.create", payload).await + self.dispatch_business("tags/create", "tags.create", payload).await } pub async fn update_tag(&self, payload: &T) -> Result<(), McpToolError> { let _: serde_json::Value = - self.proxy_main_server("tags/update", "tags.update", payload).await?; + self.dispatch_business("tags/update", "tags.update", payload).await?; Ok(()) } pub async fn delete_tag(&self, tag_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server("tags/delete", "tags.delete", &json!({ "uuid": tag_uuid })) + .dispatch_business("tags/delete", "tags.delete", &json!({ "uuid": tag_uuid })) .await?; Ok(()) } pub async fn list_workspaces(&self) -> Result { - self.proxy_main_server("workspaces/list", "workspaces.list", &json!({})).await + self.dispatch_business("workspaces/list", "workspaces.list", &json!({})).await } pub async fn get_workspace( &self, workspace_uuid: &str, ) -> Result { - self.proxy_main_server( + self.dispatch_business( "workspaces/get", "workspaces.get", &json!({ "uuid": workspace_uuid }), @@ -254,7 +254,7 @@ impl LocalApiBridge { pub async fn switch_workspace(&self, workspace_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "workspaces/switch", "workspaces.switch", &json!({ "workspace_uuid": workspace_uuid }), @@ -267,11 +267,11 @@ impl LocalApiBridge { &self, request: LocalApiListProxiesRequest, ) -> Result { - self.proxy_main_server("proxies/list", "proxies.list", &request).await + self.dispatch_business("proxies/list", "proxies.list", &request).await } pub async fn get_proxy(&self, proxy_uuid: &str) -> Result { - self.proxy_main_server( + self.dispatch_business( "proxies/detail", "proxies.detail", &json!({ "uuid": proxy_uuid }), @@ -283,7 +283,7 @@ impl LocalApiBridge { &self, payload: &T, ) -> Result { - self.proxy_main_server("proxies/create", "proxies.create", payload).await + self.dispatch_business("proxies/create", "proxies.create", payload).await } pub async fn update_proxy( @@ -291,13 +291,13 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = - self.proxy_main_server("proxies/update", "proxies.update", payload).await?; + self.dispatch_business("proxies/update", "proxies.update", payload).await?; Ok(()) } pub async fn delete_proxy(&self, proxy_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "proxies/delete", "proxies.delete", &json!({ "uuid": proxy_uuid }), @@ -308,7 +308,7 @@ impl LocalApiBridge { pub async fn batch_delete_proxies(&self, proxy_uuids: Vec) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "proxies/batch-delete", "proxies.batch-delete", &json!({ "uuids": proxy_uuids }), @@ -321,7 +321,7 @@ impl LocalApiBridge { &self, request: LocalApiListBrowserKernelsRequest, ) -> Result { - self.proxy_main_server("browser-kernels/list", "browser-kernels.list", &request) + self.dispatch_business("browser-kernels/list", "browser-kernels.list", &request) .await } @@ -330,7 +330,7 @@ impl LocalApiBridge { env_uuids: Vec, ) -> Result { - self.proxy_main_server( + self.dispatch_business( "environments/batch-detail", "environments.batch-detail", &json!({ "uuids": env_uuids }), @@ -342,7 +342,7 @@ impl LocalApiBridge { &self, payload: &T, ) -> Result { - self.proxy_main_server("environments/create", "environments.create", payload) + self.dispatch_business("environments/create", "environments.create", payload) .await } @@ -351,14 +351,14 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server("environments/update", "environments.update", payload) + .dispatch_business("environments/update", "environments.update", payload) .await?; Ok(()) } pub async fn delete_environment(&self, env_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/delete", "environments.delete", &json!({ "uuid": env_uuid }), @@ -372,7 +372,7 @@ impl LocalApiBridge { env_uuids: Vec, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/batch-delete", "environments.batch-delete", &json!({ "uuids": env_uuids }), @@ -386,7 +386,7 @@ impl LocalApiBridge { request: LocalApiListEnvironmentsRequest, ) -> Result { - self.proxy_main_server( + self.dispatch_business( "environments/recycle-bin/list", "environments.recycle-bin.list", &request, @@ -396,7 +396,7 @@ impl LocalApiBridge { pub async fn restore_environment(&self, env_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/recycle-bin/restore", "environments.recycle-bin.restore", &json!({ "uuid": env_uuid }), @@ -410,7 +410,7 @@ impl LocalApiBridge { env_uuids: Vec, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/recycle-bin/batch-restore", "environments.recycle-bin.batch-restore", &json!({ "uuids": env_uuids }), @@ -421,7 +421,7 @@ impl LocalApiBridge { pub async fn permanent_delete_environment(&self, env_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/recycle-bin/permanent-delete", "environments.recycle-bin.permanent-delete", &json!({ "uuid": env_uuid }), @@ -435,7 +435,7 @@ impl LocalApiBridge { env_uuids: Vec, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/recycle-bin/batch-permanent-delete", "environments.recycle-bin.batch-permanent-delete", &json!({ "uuids": env_uuids }), @@ -449,7 +449,7 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server("environments/set-proxy", "environments.set-proxy", payload) + .dispatch_business("environments/set-proxy", "environments.set-proxy", payload) .await?; Ok(()) } @@ -459,7 +459,7 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/set-accounts", "environments.set-accounts", payload, @@ -473,7 +473,7 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/assign-tags", "environments.assign-tags", payload, @@ -487,7 +487,7 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/batch-assign-tags", "environments.batch-assign-tags", payload, @@ -498,7 +498,7 @@ impl LocalApiBridge { pub async fn remove_tag(&self, payload: &T) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/remove-tag", "environments.remove-tag", payload, @@ -512,7 +512,7 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/batch-remove-tags", "environments.batch-remove-tags", payload, @@ -526,7 +526,7 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/move-to-group", "environments.move-to-group", payload, @@ -540,7 +540,7 @@ impl LocalApiBridge { payload: &T, ) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/batch-move-to-group", "environments.batch-move-to-group", payload, @@ -553,7 +553,7 @@ impl LocalApiBridge { &self, env_uuid: &str, ) -> Result, McpToolError> { - self.proxy_main_server( + self.dispatch_business( "environments/urls/list", "environments.urls.list", &json!({ "uuid": env_uuid }), @@ -565,13 +565,13 @@ impl LocalApiBridge { &self, payload: &T, ) -> Result { - self.proxy_main_server("environments/urls/add", "environments.urls.add", payload) + self.dispatch_business("environments/urls/add", "environments.urls.add", payload) .await } pub async fn delete_environment_url(&self, id: i32) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/urls/delete", "environments.urls.delete", &json!({ "id": id }), @@ -582,7 +582,7 @@ impl LocalApiBridge { pub async fn clear_environment_urls(&self, env_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/urls/clear", "environments.urls.clear", &json!({ "environment_uuid": env_uuid }), @@ -595,7 +595,7 @@ impl LocalApiBridge { &self, env_uuid: &str, ) -> Result, McpToolError> { - self.proxy_main_server( + self.dispatch_business( "environments/cookies/list", "environments.cookies.list", &json!({ "uuid": env_uuid }), @@ -607,7 +607,7 @@ impl LocalApiBridge { &self, payload: &T, ) -> Result { - self.proxy_main_server( + self.dispatch_business( "environments/cookies/add", "environments.cookies.add", payload, @@ -617,7 +617,7 @@ impl LocalApiBridge { pub async fn delete_environment_cookie(&self, id: i32) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/cookies/delete", "environments.cookies.delete", &json!({ "id": id }), @@ -628,7 +628,7 @@ impl LocalApiBridge { pub async fn clear_environment_cookies(&self, env_uuid: &str) -> Result<(), McpToolError> { let _: serde_json::Value = self - .proxy_main_server( + .dispatch_business( "environments/cookies/clear", "environments.cookies.clear", &json!({ "environment_uuid": env_uuid }), @@ -637,9 +637,9 @@ impl LocalApiBridge { Ok(()) } - async fn proxy_main_server( + async fn dispatch_business( &self, - server_path: &str, + route: &str, permission_code: &str, payload: &P, ) -> Result @@ -650,7 +650,7 @@ impl LocalApiBridge { let payload = serde_json::to_value(payload) .map_err(|error| McpToolError::internal(error.to_string()))?; - proxy_data_request::(server_path, permission_code, &self.api_key, payload) + dispatch_data_request::(route, permission_code, &self.api_key, payload) .await .map_err(map_proxy_error) } diff --git a/src-tauri/src/mcp/manager.rs b/src-tauri/src/mcp/manager.rs index 26200d6a..7a3bc726 100644 --- a/src-tauri/src/mcp/manager.rs +++ b/src-tauri/src/mcp/manager.rs @@ -1,12 +1,10 @@ use std::sync::Arc; use std::time::Duration; -use serde_json::json; +use tauri::Manager; use tokio::{sync::Mutex, time::sleep}; use crate::{ - app::context::AppContext, - infrastructure::http::client::JsonRespnse, local_api::types::LocalApiRuntimeConfig, mcp::{ config::{McpConfig, McpManagerStatus, McpServerRuntimeConfig, load_config}, @@ -99,16 +97,17 @@ impl McpManager { } async fn fetch_local_api_runtime_config() -> Result { - let ctx = AppContext::get(); - let response: JsonRespnse = ctx - .main_server_client - .post("local-api/get", &json!({})) - .await - .map_err(|error| error.to_string())?; - - let data = response.data.ok_or_else(|| "missing local api config data".to_string())?; - - serde_json::from_value::(data).map_err(|error| error.to_string()) + let app = crate::app::handle::get_app_handle().map_err(|error| error.to_string())?; + let context = app.state::(); + let config = business::services::local_api::get_local_api_config_service( + &context, + context.local_user_uuid, + ) + .await + .map_err(|error| error.to_string())?; + serde_json::to_value(config) + .and_then(serde_json::from_value::) + .map_err(|error| error.to_string()) } async fn wait_for_mcp_server_ready(health_url: &str) -> Result<(), String> { diff --git a/src-tauri/src/mcp/tools/browser_kernels.rs b/src-tauri/src/mcp/tools/browser_kernels.rs index 3ae29257..5989fb9c 100644 --- a/src-tauri/src/mcp/tools/browser_kernels.rs +++ b/src-tauri/src/mcp/tools/browser_kernels.rs @@ -37,6 +37,7 @@ pub struct BrowserKernelGroupSummary { #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct BrowserKernelVersionSummary { + pub kernel_id: String, pub resource_name: String, pub version: String, pub name: Option, @@ -103,6 +104,7 @@ fn map_browser_kernel_version( version: LocalApiBrowserKernelVersion, ) -> BrowserKernelVersionSummary { BrowserKernelVersionSummary { + kernel_id: version.kernel_id, resource_name: version.resource_name, version: version.version, name: version.name, diff --git a/src-tauri/src/services/auth/credential.rs b/src-tauri/src/services/auth/credential.rs deleted file mode 100644 index 98e8753e..00000000 --- a/src-tauri/src/services/auth/credential.rs +++ /dev/null @@ -1,54 +0,0 @@ -/// 凭证服务 -/// -/// 封装凭证管理相关的业务逻辑 -use crate::core::error::Result; -use crate::infrastructure::persistence::credential::remembered; -use crate::infrastructure::persistence::credential::{clear_credential, get_credential, is_login}; -use crate::local_api; - -/// 凭证服务 -pub struct CredentialService; - -impl CredentialService { - /// 退出登录 - pub async fn logout() -> Result<()> { - clear_credential(); - remembered::clear_remembered_credential()?; - log::trace!("用户已退出登录"); - - if let Some(ctx) = crate::app::context::AppContext::try_get() { - ctx.simprint_runtime_manager.stop().await; - } - local_api::stop_runtime(); - - Ok(()) - } - - /// 获取访问令牌 - pub fn get_access_token() -> Result { - get_credential().get_access_token().ok_or_else(|| "获取登录凭证失败".into()) - } - - /// 检查是否已登录 - pub fn is_logged_in() -> bool { - is_login() - } - - /// 保存记住的凭证 - pub fn save_remembered_credential(email: String, refresh_token: String) -> Result<()> { - remembered::save_remembered_credential(email, refresh_token)?; - Ok(()) - } - - /// 获取记住的凭证 - pub fn get_remembered_credential() -> Result> { - Ok(remembered::get_remembered_credential()?) - } - - /// 清除记住的凭证 - pub fn clear_remembered_credential() -> Result<()> { - remembered::clear_remembered_credential()?; - log::trace!("记住的凭证已清除"); - Ok(()) - } -} diff --git a/src-tauri/src/services/auth/login.rs b/src-tauri/src/services/auth/login.rs deleted file mode 100644 index d2a89279..00000000 --- a/src-tauri/src/services/auth/login.rs +++ /dev/null @@ -1,144 +0,0 @@ -/// 登录服务 -/// -/// 封装登录相关的业务逻辑 -use crate::app::context::AppContext; -use crate::core::error::Result; -use crate::domain::credential::Credential; -use crate::infrastructure::http::client::JsonRespnse; -use crate::infrastructure::persistence::credential::{set_access_token, set_refresh_token}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -/// 基本登录请求(邮箱 + 密码) -#[derive(Serialize, Deserialize)] -pub struct BasicLoginRequest { - pub email: String, - pub password: String, -} - -/// 记住密码登录请求 -#[derive(Serialize, Deserialize)] -pub struct RememberPasswordLoginRequest { - pub email: String, - pub refresh_token: String, -} - -/// 登录方式 -#[derive(Serialize, Deserialize)] -#[serde(tag = "type", content = "data", rename_all = "snake_case")] -pub enum LoginType { - /// 基本登录(邮箱 + 密码) - Basic(BasicLoginRequest), - /// 记住密码登录(邮箱 + refresh_token) - RememberPassword(RememberPasswordLoginRequest), -} - -/// 登录响应 -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct LoginResponse { - /// 访问令牌 - pub access_token: String, - /// 刷新令牌 - pub refresh_token: String, - /// 是否首次登录 - pub is_first_login: Option, - /// 用户信息 - #[serde(default)] - pub user_info: Option, - /// 用户额外信息 - #[serde(default)] - pub user_extra: Option, -} - -/// 登录服务 -pub struct LoginService; - -impl LoginService { - /// 执行登录 - pub async fn login(payload: LoginType) -> Result { - let ctx = AppContext::get(); - - // 1. 获取 RSA 公钥 - let public_key = ctx.rsa_keypair.get_public_key()?; - - // 2. 构建登录请求 - let request_payload = Self::build_login_payload(payload, public_key); - - // 3. 发起登录请求 - let result = ctx.main_server_client.post("users/login", &request_payload).await?; - - // 4. 处理登录响应 - if let Some(data) = result.data.clone() { - Self::handle_login_response(data).await?; - } - - Ok(result) - } - - /// 保存凭证 - pub async fn save_credential( - access_token: Option, - refresh_token: Option, - ) -> Result<()> { - if let Some(access_token) = access_token { - set_access_token(access_token); - } - if let Some(refresh_token) = refresh_token { - set_refresh_token(refresh_token); - } - - sync_runtime_session_state().await?; - - Ok(()) - } - - /// 构建登录请求 - fn build_login_payload(payload: LoginType, public_key: String) -> Value { - match payload { - LoginType::Basic(request) => json!({ - "login_type": "basic", - "email": request.email, - "password": request.password, - "public_secret_key": public_key, - }), - LoginType::RememberPassword(request) => json!({ - "login_type": "remember", - "email": request.email, - "refresh_token": request.refresh_token, - "public_secret_key": public_key, - }), - } - } - - /// 处理登录响应 - async fn handle_login_response(data: Value) -> Result<()> { - // 解析登录响应 - let login_response: LoginResponse = serde_json::from_value(data)?; - - // 使用领域对象创建凭证 - let credential = Credential::new( - login_response.access_token.clone(), - login_response.refresh_token.clone(), - ); - - // 验证凭证 - if !credential.is_valid() { - return Err("登录响应中的凭证无效".into()); - } - - // 保存凭证到存储层 - set_access_token(credential.access_token().to_string()); - set_refresh_token(credential.refresh_token().to_string()); - sync_runtime_session_state().await?; - - Ok(()) - } -} - -pub async fn sync_runtime_session_state() -> Result<()> { - if let Some(ctx) = AppContext::try_get() { - ctx.simprint_runtime_manager.sync_session_state().await?; - } - - Ok(()) -} diff --git a/src-tauri/src/services/auth/mod.rs b/src-tauri/src/services/auth/mod.rs deleted file mode 100644 index 1aad354b..00000000 --- a/src-tauri/src/services/auth/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -/// 认证服务模块 -/// -/// 提供登录、注册和凭证管理的业务逻辑 -pub mod credential; -pub mod login; -pub mod register; - -pub use credential::CredentialService; -pub use login::{ - BasicLoginRequest, LoginResponse, LoginService, LoginType, RememberPasswordLoginRequest, -}; -pub use register::{RegisterRequest, RegisterService}; diff --git a/src-tauri/src/services/auth/register.rs b/src-tauri/src/services/auth/register.rs deleted file mode 100644 index 9a05399a..00000000 --- a/src-tauri/src/services/auth/register.rs +++ /dev/null @@ -1,169 +0,0 @@ -/// 注册服务 -/// -/// 封装注册相关的业务逻辑 -use crate::app::context::AppContext; -use crate::core::error::Result; -use crate::domain::credential::Credential; -use crate::domain::referral_code::ReferralCode; -use crate::infrastructure::http::client::JsonRespnse; -use crate::infrastructure::persistence::credential::{set_access_token, set_refresh_token}; -use crate::services::auth::login::LoginResponse; -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use regex::Regex; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use std::fs; - -/// 注册请求 -#[derive(Serialize, Deserialize)] -pub struct RegisterRequest { - pub email: String, - pub password: String, - pub code: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub referral_code: Option, -} - -/// 注册服务 -pub struct RegisterService; - -impl RegisterService { - /// 执行注册 - pub async fn register(payload: RegisterRequest) -> Result { - let ctx = AppContext::get(); - - // 1. 获取 RSA 公钥 - let public_key = ctx.rsa_keypair.get_public_key()?; - - // 2. 从邮箱提取 nickname - let nickname = payload.email.split('@').next().map(|s| s.to_string()).unwrap_or_default(); - - // 3. 处理邀请码 - let referral_code = Self::resolve_referral_code(payload.referral_code); - - // 4. 构建注册请求 - let request_payload = Self::build_register_payload( - payload.email, - payload.password, - payload.code, - nickname, - public_key, - referral_code, - ); - - // 5. 发起注册请求 - let result = ctx.main_server_client.post("users/register", &request_payload).await?; - - // 6. 处理注册响应 - if let Some(data) = result.data.clone() { - Self::handle_register_response(data).await?; - } - - Ok(result) - } - - /// 构建注册请求 - fn build_register_payload( - email: String, - password: String, - code: String, - nickname: String, - public_key: String, - referral_code: Option, - ) -> Value { - let mut payload = json!({ - "email": email, - "password": password, - "code": code, - "nickname": nickname, - "public_secret_key": public_key, - }); - - if let Some(code) = referral_code { - payload["referral_code"] = json!(code); - } - - payload - } - - /// 处理注册响应 - async fn handle_register_response(data: Value) -> Result<()> { - let login_response: LoginResponse = serde_json::from_value(data)?; - - // 使用领域对象创建凭证 - let credential = Credential::new( - login_response.access_token.clone(), - login_response.refresh_token.clone(), - ); - - // 验证凭证 - if !credential.is_valid() { - return Err("注册响应中的凭证无效".into()); - } - - // 保存凭证到存储层 - set_access_token(credential.access_token().to_string()); - set_refresh_token(credential.refresh_token().to_string()); - crate::services::auth::login::sync_runtime_session_state().await?; - - Ok(()) - } - - /// 解析邀请码 - fn resolve_referral_code(user_provided: Option) -> Option { - // 优先使用用户提供的邀请码 - let code = user_provided - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .or_else(|| { - // 从安装目录读取邀请码 - Self::read_installed_referral_code().and_then(Self::decode_referral_code) - }); - - code - } - - /// 解码邀请码(base64url) - fn decode_referral_code(encoded: String) -> Option { - // 使用领域对象解码邀请码 - ReferralCode::decode(&encoded) - .ok() - .filter(|code| code.is_valid()) - .map(|code| code.code().to_string()) - } - - /// 从统一路径中的 referral 目录读取邀请码 - fn read_installed_referral_code() -> Option { - let referral_dir = crate::core::paths::PathManager::get_referral_dir().ok()?; - - if !referral_dir.is_dir() { - return None; - } - - // 提取邀请码 - let code_opt: Option = (|| { - let mut entries: Vec<_> = - fs::read_dir(&referral_dir).ok()?.filter_map(|e| e.ok()).collect(); - entries.sort_by_key(|e| e.file_name()); - - let first_entry = entries.into_iter().next()?; - let path = first_entry.path(); - if !path.is_file() { - return None; - } - - let file_name = path.file_name()?.to_string_lossy(); - let re = Regex::new(r"R_(.+)_R").ok()?; - let cap = re.captures(file_name.as_ref())?; - cap.get(1).map(|m| m.as_str().to_string()) - })(); - - // 清理 referral 目录 - let _ = fs::remove_dir_all(&referral_dir); - - code_opt - } -} diff --git a/src-tauri/src/services/environment/kernel/downloader.rs b/src-tauri/src/services/environment/kernel/downloader.rs index 8e2bbac9..4d7e56ef 100644 --- a/src-tauri/src/services/environment/kernel/downloader.rs +++ b/src-tauri/src/services/environment/kernel/downloader.rs @@ -1,8 +1,8 @@ //! 内核下载模块 use crate::core::error::Result; +use crate::core::utils::hash::calculate_file_hash; use crate::domain::environment::{EnvironmentStatus, KernelDetail}; -use crate::infrastructure::updater::planner::calculate_file_hash; use std::fs; use std::path::{Path, PathBuf}; use uuid::Uuid; @@ -122,15 +122,27 @@ pub async fn download_and_install_kernel( /// 下载文件 async fn download_file( - app: &tauri::AppHandle, + _app: &tauri::AppHandle, env_uuid: &Option, kernel_value: &str, url: &str, zip_path: &Path, status_emitter: Option, ) -> Result<()> { + let display_url = redact_download_url(url); + crate::log_info!( + crate::core::logger::modules::KERNEL, + "开始下载内核: {}", + display_url + ); let client = reqwest::Client::new(); let res = client.get(url).send().await.map_err(|e| { + crate::log_error!( + crate::core::logger::modules::KERNEL, + "内核下载请求失败: {} - {}", + display_url, + e + ); emit_status( status_emitter.as_ref(), env_uuid, @@ -145,6 +157,12 @@ async fn download_file( })?; if !res.status().is_success() { + crate::log_error!( + crate::core::logger::modules::KERNEL, + "内核下载失败: HTTP {} - {}", + res.status(), + display_url + ); emit_status( status_emitter.as_ref(), env_uuid, @@ -245,6 +263,19 @@ async fn download_file( Ok(()) } +fn redact_download_url(url: &str) -> String { + match reqwest::Url::parse(url) { + Ok(mut parsed) => { + let _ = parsed.set_username(""); + let _ = parsed.set_password(None); + parsed.set_query(None); + parsed.set_fragment(None); + parsed.to_string() + } + Err(_) => "".to_string(), + } +} + /// 校验下载文件哈希 fn verify_download_hash( env_uuid: &Option, @@ -387,3 +418,22 @@ fn verify_core_dll_signature( Ok(()) } + +#[cfg(test)] +mod tests { + use super::redact_download_url; + + #[test] + fn download_log_url_does_not_include_credentials() { + assert_eq!( + redact_download_url( + "https://user:password@example.test/kernel.zip?token=secret#fragment" + ), + "https://example.test/kernel.zip" + ); + assert_eq!( + redact_download_url("not a url"), + "" + ); + } +} diff --git a/src-tauri/src/services/environment/kernel/extension.rs b/src-tauri/src/services/environment/kernel/extension.rs index bf479fef..0809179a 100644 --- a/src-tauri/src/services/environment/kernel/extension.rs +++ b/src-tauri/src/services/environment/kernel/extension.rs @@ -276,7 +276,7 @@ fn copy_local_extension_crx(source_path: &str, crx_path: &Path) -> Result<()> { /// 校验扩展文件哈希 fn verify_extension_hash(crx_path: &Path, expected_hash: &str) -> Result<()> { - use crate::infrastructure::updater::planner::calculate_file_hash; + use crate::core::utils::hash::calculate_file_hash; let actual_hash = calculate_file_hash(crx_path)?; let expected = expected_hash.trim().to_lowercase(); diff --git a/src-tauri/src/services/environment/kernel/mod.rs b/src-tauri/src/services/environment/kernel/mod.rs index c4c9963f..76417655 100644 --- a/src-tauri/src/services/environment/kernel/mod.rs +++ b/src-tauri/src/services/environment/kernel/mod.rs @@ -4,7 +4,7 @@ use crate::core::error::Result; use crate::domain::environment::{EnvironmentStatus, KernelDetail}; -use std::fs; +use tauri::Manager; pub mod downloader; pub mod extension; @@ -19,13 +19,32 @@ pub mod verifier; // 重新导出常用类型 pub use types::{ AccountInfo, BatchLaunchRequest, BatchLaunchResult, CdpEndpointResponse, CookieGroup, - ExtensionInfo, KernelPrepareStatusPayload, KernelStatusEmitter, ProxyConfig, RpaTabInfo, - RpaTabCloseResult, RpaTabSelection, RpaTabsSnapshot, + ExtensionInfo, KernelPrepareStatusPayload, KernelStatusEmitter, ProxyConfig, RpaTabCloseResult, + RpaTabInfo, RpaTabSelection, RpaTabsSnapshot, }; /// 内核服务 pub struct KernelService; +async fn record_ready_installation( + app: &tauri::AppHandle, + kernel_id: &str, + exe_path: &std::path::Path, + verified_signature: &str, +) { + let context = app.state::(); + if let Err(error) = business::services::browser_kernels::record_kernel_installation( + &context.db, + kernel_id, + &exe_path.to_string_lossy(), + verified_signature, + ) + .await + { + log::warn!("Failed to record browser kernel installation: {error}"); + } +} + impl KernelService { /// 确保内核已就绪:目录不存在则下载并解压,存在则校验 chrome.dll 哈希 pub async fn ensure_kernel_ready( @@ -36,119 +55,173 @@ impl KernelService { kernel_detail: KernelDetail, status_emitter: Option, ) -> Result { - let kernel_value = kernel_value.trim().to_string(); - if kernel_value.is_empty() { + Self::ensure_kernel_ready_for_artifact( + app, + env_uuid, + kernel_value.clone(), + kernel_value, + profiles_path, + kernel_detail, + status_emitter, + ) + .await + } + + /// Prepare a registry artifact while keeping its immutable identity + /// separate from the backwards-compatible on-disk directory name. + pub async fn ensure_kernel_ready_for_artifact( + app: tauri::AppHandle, + env_uuid: Option, + kernel_id: String, + install_dir_name: String, + profiles_path: String, + kernel_detail: KernelDetail, + status_emitter: Option, + ) -> Result { + let kernel_id = kernel_id.trim().to_string(); + if kernel_id.is_empty() { + return Err("内核标识不能为空".into()); + } + let install_dir_name = install_dir_name.trim().to_string(); + if install_dir_name.is_empty() { return Err("内核版本不能为空".into()); } - let _prepare_guard = state::acquire_kernel_prepare_lock(&kernel_value).await; + let _prepare_guard = state::acquire_kernel_prepare_lock(&kernel_id).await; let base = utils::resolve_profiles_base(&app, &profiles_path)?; - let kernel_dir = base.join(&kernel_value); + let kernel_dir = utils::resolve_kernel_install_dir(&base, &install_dir_name)?; let exe_path = kernel_dir.join(utils::exe_name()); // 目录已存在:校验内核 - if kernel_dir.exists() && kernel_dir.is_dir() { + if kernel_dir.exists() { + if !kernel_dir.is_dir() { + return Err(format!("内核安装路径不是目录: {}", kernel_dir.display()).into()); + } // 检查可执行文件是否存在 if !exe_path.exists() { crate::log_warn!( crate::core::logger::modules::KERNEL, - "内核目录存在但未找到可执行文件,删除目录并重新下载: {}", + "内核目录存在但未找到可执行文件,保留目录并停止启动: {}", kernel_dir.display() ); utils::emit_status( status_emitter.as_ref(), &env_uuid, - &kernel_value, + &install_dir_name, EnvironmentStatus::Error, - Some("未找到可执行文件,重新下载"), + Some("内核目录不完整"), None, None, None, ); - let _ = fs::remove_dir_all(&kernel_dir); + return Err( + format!("内核目录不完整,已保留原目录: {}", kernel_dir.display()).into(), + ); } else { // 校验 signature - let signature = kernel_detail + let primary_signature = kernel_detail .signature .as_deref() .filter(|s| !s.trim().is_empty()) .ok_or("该内核版本缺少 signature,无法校验核心 DLL")?; + let mut accepted_signatures = vec![primary_signature.to_string()]; + for signature in &kernel_detail.compatible_signatures { + if !signature.trim().is_empty() + && !accepted_signatures + .iter() + .any(|current| current.eq_ignore_ascii_case(signature.trim())) + { + accepted_signatures.push(signature.trim().to_string()); + } + } match verifier::verify_kernel( &app, &env_uuid, - &kernel_value, + &install_dir_name, &kernel_dir, - signature, + &accepted_signatures, status_emitter.clone(), ) { - Ok(true) => { + Ok(Some(verified_signature)) => { // 校验通过 utils::emit_status( status_emitter.as_ref(), &env_uuid, - &kernel_value, + &install_dir_name, EnvironmentStatus::Ready, Some("就绪"), None, None, None, ); + record_ready_installation(&app, &kernel_id, &exe_path, &verified_signature) + .await; return Ok(exe_path.to_string_lossy().to_string()); } - Ok(false) => { - // 校验失败,删除目录重新下载 + Ok(None) => { crate::log_warn!( crate::core::logger::modules::KERNEL, - "内核校验失败,删除目录并重新下载: {}", + "内核校验失败,保留目录并停止启动: {}", kernel_dir.display() ); utils::emit_status( status_emitter.as_ref(), &env_uuid, - &kernel_value, + &install_dir_name, EnvironmentStatus::Error, - Some("校验失败,重新下载"), + Some("内核校验失败"), None, None, None, ); - let _ = fs::remove_dir_all(&kernel_dir); + return Err(format!( + "内核校验失败,已保留原目录: {}", + kernel_dir.display() + ) + .into()); } Err(e) => { crate::log_warn!( crate::core::logger::modules::KERNEL, - "内核校验出错,删除目录并重新下载: {} - {}", + "内核校验出错,保留目录并停止启动: {} - {}", kernel_dir.display(), e ); utils::emit_status( status_emitter.as_ref(), &env_uuid, - &kernel_value, + &install_dir_name, EnvironmentStatus::Error, - Some("校验出错,重新下载"), + Some("内核校验出错"), None, None, None, ); - let _ = fs::remove_dir_all(&kernel_dir); + return Err(format!( + "内核校验出错,已保留原目录 {}: {e}", + kernel_dir.display() + ) + .into()); } } } } - // 目录不存在或校验失败:下载并安装 + // 只有目录不存在时才下载;现有安装绝不在替代包准备好之前删除。 let exe_path = downloader::download_and_install_kernel( &app, &env_uuid, - &kernel_value, + &install_dir_name, &kernel_dir, &kernel_detail, status_emitter, ) .await?; + let verified_signature = kernel_detail.signature.as_deref().unwrap_or_default(); + record_ready_installation(&app, &kernel_id, &exe_path, verified_signature).await; + Ok(exe_path.to_string_lossy().to_string()) } diff --git a/src-tauri/src/services/environment/kernel/runtime_bridge.rs b/src-tauri/src/services/environment/kernel/runtime_bridge.rs index 57a12d42..60a2c701 100644 --- a/src-tauri/src/services/environment/kernel/runtime_bridge.rs +++ b/src-tauri/src/services/environment/kernel/runtime_bridge.rs @@ -199,7 +199,7 @@ pub async fn refresh_proxy(env_uuid: String, proxy: Option) -> Resu } pub async fn get_connected_environments() -> Result> { - if !crate::infrastructure::persistence::credential::is_login() { + if !crate::commands::auth::has_local_session() { return Ok(vec![]); } @@ -215,7 +215,7 @@ pub async fn get_connected_environments() -> Result> { } pub async fn get_cdp_endpoint(env_uuid: String) -> Result> { - if !crate::infrastructure::persistence::credential::is_login() { + if !crate::commands::auth::has_local_session() { return Ok(None); } @@ -297,7 +297,7 @@ pub async fn close_rpa_tab(env_uuid: String, position: u32) -> Result Result> { - if !crate::infrastructure::persistence::credential::is_login() { + if !crate::commands::auth::has_local_session() { return Ok(None); } @@ -313,7 +313,7 @@ pub async fn get_environment_status(env_uuid: String) -> Result Result> { - if !crate::infrastructure::persistence::credential::is_login() { + if !crate::commands::auth::has_local_session() { return Ok(HashMap::new()); } diff --git a/src-tauri/src/services/environment/kernel/utils.rs b/src-tauri/src/services/environment/kernel/utils.rs index 0a9d9e11..ccf8ba99 100644 --- a/src-tauri/src/services/environment/kernel/utils.rs +++ b/src-tauri/src/services/environment/kernel/utils.rs @@ -27,6 +27,17 @@ pub fn resolve_profiles_base( crate::core::paths::PathManager::get_profiles_dir(app).map_err(Into::into) } +/// Resolve a catalog-provided installation directory without allowing it to +/// escape the profiles root. +pub fn resolve_kernel_install_dir(base: &Path, install_dir_name: &str) -> Result { + let install_dir_name = install_dir_name.trim(); + let mut components = Path::new(install_dir_name).components(); + match (components.next(), components.next()) { + (Some(std::path::Component::Normal(_)), None) => Ok(base.join(install_dir_name)), + _ => Err("内核安装目录名称无效".into()), + } +} + /// 将 zip 解压到目标目录 pub fn extract_zip_to_dir(zip_path: &Path, target_dir: &Path) -> Result<()> { let file = fs::File::open(zip_path)?; @@ -136,3 +147,21 @@ pub fn emit_status( }); } } + +#[cfg(test)] +mod tests { + use super::resolve_kernel_install_dir; + use std::path::Path; + + #[test] + fn kernel_install_dir_must_be_one_normal_component() { + let base = Path::new("profiles"); + assert_eq!( + resolve_kernel_install_dir(base, "Chrome 144").unwrap(), + base.join("Chrome 144") + ); + assert!(resolve_kernel_install_dir(base, "").is_err()); + assert!(resolve_kernel_install_dir(base, "..").is_err()); + assert!(resolve_kernel_install_dir(base, "nested/kernel").is_err()); + } +} diff --git a/src-tauri/src/services/environment/kernel/verifier.rs b/src-tauri/src/services/environment/kernel/verifier.rs index 06f24724..f206a6f4 100644 --- a/src-tauri/src/services/environment/kernel/verifier.rs +++ b/src-tauri/src/services/environment/kernel/verifier.rs @@ -11,17 +11,17 @@ use super::utils::{calculate_file_hash_head, core_dll_name, emit_status, extract /// 校验内核完整性 /// /// # 返回 -/// - `Ok(true)`: 校验通过 -/// - `Ok(false)`: 校验失败,需要重新下载 +/// - `Ok(Some(signature))`: 校验通过,并返回实际匹配的签名 +/// - `Ok(None)`: 校验失败 /// - `Err`: 发生错误 pub fn verify_kernel( - app: &tauri::AppHandle, + _app: &tauri::AppHandle, env_uuid: &Option, kernel_value: &str, kernel_dir: &Path, - expected_signature: &str, + expected_signatures: &[String], status_emitter: Option, -) -> Result { +) -> Result> { emit_status( status_emitter.as_ref(), env_uuid, @@ -56,7 +56,7 @@ pub fn verify_kernel( kernel_dir.display(), e ); - return Ok(false); + return Ok(None); } }; @@ -73,15 +73,22 @@ pub fn verify_kernel( "未找到核心 DLL 文件: {}", dll_path.display() ); - return Ok(false); + return Ok(None); } // 校验 signature - let expected = expected_signature.trim().to_lowercase(); + let expected = expected_signatures + .iter() + .map(|signature| signature.trim().to_lowercase()) + .filter(|signature| !signature.is_empty()) + .collect::>(); + if expected.is_empty() { + return Err("该内核版本缺少 signature,无法校验核心 DLL".into()); + } crate::log_info!( crate::core::logger::modules::KERNEL, - "开始计算 DLL 哈希,期望值: {}", - expected + "开始计算 DLL 哈希,可接受值: {}", + expected.join(", ") ); let local_hash = calculate_file_hash_head(&dll_path, SIGNATURE_HASH_SIZE)?; @@ -93,14 +100,22 @@ pub fn verify_kernel( local ); - if local != expected { + let Some(matched_index) = expected.iter().position(|signature| signature == &local) else { + crate::log_warn!( + crate::core::logger::modules::KERNEL, + "核心 DLL 哈希不一致,可接受值: {}, 实际: {}", + expected.join(", "), + local + ); + return Ok(None); + }; + + if matched_index > 0 { crate::log_warn!( crate::core::logger::modules::KERNEL, - "核心 DLL 哈希不一致,期望: {}, 实际: {}", - expected, + "使用兼容签名接受已安装内核: {}", local ); - return Ok(false); } crate::log_info!( @@ -109,7 +124,7 @@ pub fn verify_kernel( kernel_dir.display() ); - Ok(true) + Ok(Some(local)) } /// 查找版本子目录 diff --git a/src-tauri/src/services/environment/launch_runtime/detail.rs b/src-tauri/src/services/environment/launch_runtime/detail.rs index 5f72dd38..2cbc37a5 100644 --- a/src-tauri/src/services/environment/launch_runtime/detail.rs +++ b/src-tauri/src/services/environment/launch_runtime/detail.rs @@ -1,18 +1,24 @@ use serde_json::json; +use tauri::Manager; -use crate::{app::context::AppContext, core::error::Result}; +use crate::core::error::Result; use super::types::EnvironmentLaunchDetail; pub(super) async fn get_environment_launch_detail( env_uuid: &str, ) -> Result { - let ctx = AppContext::get(); - let response = ctx - .main_server_client - .post("environments/detail", &json!({ "uuid": env_uuid })) - .await?; + let app = crate::app::handle::get_app_handle()?; + let context = app.state::(); + let request_context = context.for_current_user()?; + let data = business::dispatcher::dispatch_post( + &request_context, + "environments/detail", + &json!({ "uuid": env_uuid }), + ) + .await + .ok_or("本地环境详情路由不存在")? + .map_err(crate::core::error::Error::from)?; - let data = response.data.ok_or("获取环境详情失败")?; serde_json::from_value(data).map_err(Into::into) } diff --git a/src-tauri/src/services/environment/launch_runtime/kernel.rs b/src-tauri/src/services/environment/launch_runtime/kernel.rs index 89a8a4b1..bb3f5dd0 100644 --- a/src-tauri/src/services/environment/launch_runtime/kernel.rs +++ b/src-tauri/src/services/environment/launch_runtime/kernel.rs @@ -1,16 +1,14 @@ -use std::collections::HashMap; - -use serde_json::{Value, json}; -use tauri::AppHandle; +use serde_json::Value; +use tauri::{AppHandle, Manager}; +use uuid::Uuid; use crate::{ - app::context::AppContext, core::error::Result, domain::environment::KernelDetail, services::environment::{KernelService, KernelStatusEmitter}, }; -use super::types::{BrowserKernelVersion, EnvironmentLaunchDetail, SIMPRINT_KERNEL_CHROMIUM}; +use super::types::EnvironmentLaunchDetail; pub(super) struct ResolvedKernelLaunch { pub exe_path: String, @@ -26,45 +24,35 @@ pub(super) async fn resolve_kernel_launch( .environment .as_ref() .ok_or("Environment detail is missing environment uuid.")?; - let window_info = get_window_info(detail.config.as_ref()); - - let kernel_value = window_info - .get("kernel") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or("This environment has no browser kernel configured yet.")? - .to_string(); - - let kernels_map = list_browser_kernels(host_platform()).await?; - let kernel_detail = kernels_map - .get(SIMPRINT_KERNEL_CHROMIUM) - .and_then(|list| list.iter().find(|kernel| kernel.resource_name == kernel_value)) - .cloned() - .ok_or_else(|| format!("No browser kernel matched \"{}\".", kernel_value))?; + let environment_uuid = Uuid::parse_str(&env.uuid) + .map_err(|error| format!("Invalid environment uuid {}: {error}", env.uuid))?; + let context = app.state::(); + let kernel_detail = + business::services::browser_kernels::get_environment_kernel(&context.db, environment_uuid) + .await? + .ok_or("This environment has no browser kernel binding.")?; + let kernel_id = kernel_detail.kernel_id.clone(); + let install_dir_name = kernel_detail.install_dir_name.clone(); let url = kernel_detail .url .filter(|value| !value.trim().is_empty()) .ok_or("The selected browser kernel is missing download metadata.")?; - let hash = kernel_detail - .hash - .filter(|value| !value.trim().is_empty()) - .ok_or("The selected browser kernel is missing download metadata.")?; - let signature = kernel_detail - .signature - .filter(|value| !value.trim().is_empty()) - .ok_or("The selected browser kernel is missing signature metadata.")?; + let hash = kernel_detail.hash; + let signature = kernel_detail.signature; + let compatible_signatures = kernel_detail.compatible_signatures.0; - let exe_path = KernelService::ensure_kernel_ready( + let exe_path = KernelService::ensure_kernel_ready_for_artifact( app, Some(env.uuid.clone()), - kernel_value, + kernel_id, + install_dir_name, profiles_path.to_string(), KernelDetail { url, hash, signature: Some(signature), + compatible_signatures, requires_extract: kernel_detail.requires_extract, }, status_emitter, @@ -81,44 +69,3 @@ pub(super) fn get_window_info(config: Option<&Value>) -> serde_json::Map Result>> { - let ctx = AppContext::get(); - let response = ctx - .main_server_client - .post( - "browser-kernels/list", - &json!({ - "platform": platform, - "type_code": SIMPRINT_KERNEL_CHROMIUM, - }), - ) - .await?; - - let data = response.data.ok_or("获取浏览器内核失败")?; - serde_json::from_value(data).map_err(Into::into) -} - -fn host_platform() -> &'static str { - #[cfg(target_os = "windows")] - { - "windows" - } - - #[cfg(target_os = "macos")] - { - "darwin" - } - - #[cfg(target_os = "linux")] - { - "linux" - } - - #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] - { - "windows" - } -} diff --git a/src-tauri/src/services/environment/launch_runtime/mod.rs b/src-tauri/src/services/environment/launch_runtime/mod.rs index 056ea068..55b9920b 100644 --- a/src-tauri/src/services/environment/launch_runtime/mod.rs +++ b/src-tauri/src/services/environment/launch_runtime/mod.rs @@ -82,9 +82,8 @@ impl EnvironmentLaunchRuntimeService { ) -> Result> { let app = get_app_handle()?; let requests = try_join_all(env_uuids.into_iter().map(|env_uuid| { - let display_id = display_ids_by_env_uuid - .as_ref() - .and_then(|items| items.get(&env_uuid).cloned()); + let display_id = + display_ids_by_env_uuid.as_ref().and_then(|items| items.get(&env_uuid).cloned()); Self::build_launch_request( app.clone(), env_uuid, @@ -156,7 +155,12 @@ fn resolve_environment_proxy_config( fn resolve_local_proxy_config(app: &AppHandle, env_uuid: &str) -> LocalProxyResolution { let bindings = tauri_store::get_store_key(app, ENVIRONMENT_LOCAL_PROXY_BINDINGS_STORE_KEY) - .and_then(|value| serde_json::from_value::>(value).ok()) + .and_then(|value| { + serde_json::from_value::< + std::collections::HashMap, + >(value) + .ok() + }) .unwrap_or_default(); let Some(binding) = bindings.get(env_uuid) else { diff --git a/src-tauri/src/services/environment/launch_runtime/types.rs b/src-tauri/src/services/environment/launch_runtime/types.rs index e7d72551..7c9a5476 100644 --- a/src-tauri/src/services/environment/launch_runtime/types.rs +++ b/src-tauri/src/services/environment/launch_runtime/types.rs @@ -3,16 +3,6 @@ use serde_json::Value; use crate::services::environment::{AccountInfo, ExtensionInfo}; -#[derive(Debug, Clone, Deserialize)] -pub(super) struct BrowserKernelVersion { - pub resource_name: String, - pub url: Option, - pub hash: Option, - pub signature: Option, - #[serde(default)] - pub requires_extract: bool, -} - #[derive(Debug, Clone, Deserialize)] pub(super) struct EnvironmentLaunchDetail { pub environment: Option, @@ -51,8 +41,6 @@ pub(super) struct EnvironmentCookieLike { pub cookie_text: String, } -pub(super) const SIMPRINT_KERNEL_CHROMIUM: &str = "SIMPRINT_KERNEL_CHROMIUM"; - #[derive(Debug, Clone)] pub struct LaunchPaths { pub profiles_path: String, diff --git a/src-tauri/src/services/local_extensions/mod.rs b/src-tauri/src/services/local_extensions/mod.rs index a2c90dd2..8025b0a8 100644 --- a/src-tauri/src/services/local_extensions/mod.rs +++ b/src-tauri/src/services/local_extensions/mod.rs @@ -11,7 +11,7 @@ use tauri::AppHandle; use uuid::Uuid; use crate::core::error::Result; -use crate::infrastructure::updater::planner::calculate_file_hash; +use crate::core::utils::hash::calculate_file_hash; use crate::services::environment::ExtensionInfo; static REGISTRY_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 72a6cdb1..f39aeaba 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -7,11 +7,9 @@ //! - 权限检查和验证 pub mod app; -pub mod auth; pub mod connectivity; pub mod environment; pub mod file_system; pub mod local_extensions; pub mod mihomo; -pub mod updater; pub mod window; diff --git a/src-tauri/src/services/updater/mod.rs b/src-tauri/src/services/updater/mod.rs deleted file mode 100644 index 3ee34380..00000000 --- a/src-tauri/src/services/updater/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod types; -pub mod update_service; - -pub use types::PreparedUpdateInfo; -pub use update_service::{CheckResult, DownloadResult, UpdateService}; diff --git a/src-tauri/src/services/updater/types.rs b/src-tauri/src/services/updater/types.rs deleted file mode 100644 index ea4de5ec..00000000 --- a/src-tauri/src/services/updater/types.rs +++ /dev/null @@ -1,9 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PreparedUpdateInfo { - pub kind: String, - pub version: String, - pub restart_required: bool, -} diff --git a/src-tauri/src/services/updater/update_service.rs b/src-tauri/src/services/updater/update_service.rs deleted file mode 100644 index d63b0e57..00000000 --- a/src-tauri/src/services/updater/update_service.rs +++ /dev/null @@ -1,701 +0,0 @@ -/// 更新服务 -/// -/// 提供检查更新、下载更新和启动安装的业务逻辑 -use crate::core::error::{Error, Result}; -use crate::infrastructure::updater::service::LockResult; -use crate::infrastructure::updater::types::{ - CheckingPayload, DownloadCompletePayload, DownloadPartialPayload, ErrorPayload, - FoundUpdatesPayload, InstallStrategy, LatestRelease, NoUpdatesPayload, UpdateEvent, -}; -use crate::infrastructure::updater::{checker, downloader, planner, service, verifier}; -use crate::services::updater::types::PreparedUpdateInfo; -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::sync::atomic::AtomicU64; -use std::time::Duration; -use tauri::AppHandle; -use tokio::time::Instant; - -/// 检查更新结果 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CheckResult { - /// 是否有可用更新 - pub has_updates: bool, - /// 更新数量(如果有更新) - #[serde(skip_serializing_if = "Option::is_none")] - pub update_count: Option, - /// 是否已有可用的更新计划缓存 - pub plan_available: bool, -} - -/// 下载更新结果 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DownloadResult { - /// 是否有可用更新 - pub has_updates: bool, - /// 更新任务文件路径(如果下载成功) - #[serde(skip_serializing_if = "Option::is_none")] - pub tasks_file: Option, - /// 安装策略 - pub install_strategy: InstallStrategy, - /// 成功下载的文件数量 - pub success_count: usize, -} - -pub struct UpdateService; - -fn map_update_check_error(stage: &str, err: impl std::fmt::Display) -> Error { - Error::UpdateCheckFailed(format!("{stage}: {err}")) -} - -impl UpdateService { - pub async fn get_prepared_update() -> Result> { - Ok(None) - } - - pub async fn start_prepared_update_install( - app_handle: AppHandle, - kind: Option, - ) -> Result<()> { - let resolved_kind = match kind { - Some(kind) => kind, - None => Self::get_prepared_update() - .await? - .map(|update| update.kind) - .ok_or("当前没有可安装的更新")?, - }; - - let _ = app_handle; - Err(format!( - "不支持独立更新组件: {}。环境运行时现已内嵌到 Simprint,请更新主程序。", - resolved_kind - ) - .into()) - } - - /// 简单检查是否有可用更新(仅检查,不缓存计划,不发送事件) - /// - /// 用于设置页面的「检查更新」按钮,返回布尔值。具体更新逻辑由重启时自动完成。 - pub async fn check_update_available() -> Result { - service::init_updater_config() - .map_err(|e| map_update_check_error("初始化更新配置失败", e))?; - let check_response = checker::check_updates() - .await - .map_err(|e| map_update_check_error("请求更新检查接口失败", e))?; - let tasks = planner::plan_updates(&check_response) - .map_err(|e| map_update_check_error("生成更新计划失败", e))?; - Ok(!tasks.is_empty()) - } - - /// 检查更新(仅检查,不下载) - /// - /// 检查是否有可用更新,并将更新计划缓存到内存 - /// - /// # 参数 - /// - `app_handle`: Tauri 应用句柄,用于 emit 事件 - /// - /// # 返回 - /// 返回检查结果,包含是否有更新、更新数量以及计划是否可用 - pub async fn check_updates(app_handle: AppHandle) -> Result { - // 尝试获取锁(非阻塞) - let _guard = match service::acquire_update_lock().await { - LockResult::Acquired(guard) => guard, - LockResult::Busy => { - log::debug!("更新检查正在进行中,跳过本次检查"); - return Ok(CheckResult { - has_updates: false, - update_count: None, - plan_available: false, - }); - } - }; - - // 初始化配置 - service::init_updater_config() - .map_err(|e| map_update_check_error("初始化更新配置失败", e))?; - - // 发送开始检查事件 - service::emit_event( - &app_handle, - UpdateEvent::Checking { - payload: CheckingPayload {}, - }, - ); - - // 1. 检查更新 - let check_response = checker::check_updates().await.map_err(|e| { - service::emit_error_event(&app_handle, 1, format!("检查更新失败: {}", e)); - map_update_check_error("请求更新检查接口失败", e) - })?; - - // 2. 生成更新计划 - let tasks = planner::plan_updates(&check_response).map_err(|e| { - let error_event = UpdateEvent::PlanFailed { - code: 2, - payload: ErrorPayload { - error_message: format!("生成更新计划失败: {}", e), - }, - }; - service::emit_event(&app_handle, error_event); - map_update_check_error("生成更新计划失败", e) - })?; - - if tasks.is_empty() { - log::info!("无需更新"); - service::emit_event( - &app_handle, - UpdateEvent::NoUpdates { - payload: NoUpdatesPayload {}, - }, - ); - return Ok(CheckResult { - has_updates: false, - update_count: None, - plan_available: false, - }); - } - - log::info!("发现 {} 个更新任务", tasks.len()); - - // 发送发现更新事件 - service::emit_event( - &app_handle, - UpdateEvent::FoundUpdates { - payload: FoundUpdatesPayload { - update_count: tasks.len(), - }, - }, - ); - - // 3. 缓存更新计划(供下载使用) - service::store_update_plan(&tasks).await?; - - Ok(CheckResult { - has_updates: true, - update_count: Some(tasks.len()), - plan_available: true, - }) - } - - /// 下载更新(执行下载,支持进度) - /// - /// 从内存中的更新计划读取任务,执行下载和校验,实时发送进度事件 - /// - /// # 参数 - /// - `app_handle`: Tauri 应用句柄,用于 emit 事件 - /// # 返回 - /// 返回下载结果,包含任务文件路径和成功数量 - pub async fn download_updates( - app_handle: AppHandle, - _plan_file: Option, - ) -> Result { - // 尝试获取锁(非阻塞) - let _guard = match service::acquire_update_lock().await { - LockResult::Acquired(guard) => guard, - LockResult::Busy => { - log::debug!("更新下载正在进行中,跳过本次下载"); - return Err("更新下载正在进行中".into()); - } - }; - - service::clear_installer_package_path().await; - - // 初始化配置 - service::init_updater_config()?; - - // 读取更新计划文件 - let update_plan = service::take_update_plan().await?; - - if update_plan.tasks.is_empty() { - return Err("更新计划为空".into()); - } - - // 转换为 UpdateTask - let tasks = service::plan_tasks_to_update_tasks(&update_plan.tasks); - - if should_use_installer_package(&tasks) { - return Self::download_installer_package(app_handle).await; - } - - // 计算总大小 - let total_size: u64 = tasks.iter().map(|t| t.artifact.file_size).sum(); - let downloaded_size = Arc::new(AtomicU64::new(0)); - - // 发送开始下载事件 - service::emit_event( - &app_handle, - UpdateEvent::Downloading { - payload: crate::infrastructure::updater::types::DownloadingPayload { - update_count: tasks.len(), - }, - }, - ); - - // 创建 HTTP 客户端 - let client = Client::builder().timeout(Duration::from_secs(300)).build()?; - - let mut success_count = 0; - let mut failed_count = 0; - let mut install_tasks = Vec::new(); - - // 遍历所有任务:下载和校验 - let download_start = Instant::now(); - - for task in &tasks { - let resource_name = task.artifact.resource_name.clone(); - let downloaded_size_clone = downloaded_size.clone(); - let app_handle_clone = app_handle.clone(); - let total_size_clone = total_size; - - // 1. 下载阶段(带进度回调) - // 记录该文件开始下载时的已下载总量 - let file_start_downloaded = - downloaded_size_clone.load(std::sync::atomic::Ordering::Relaxed); - - let download_result = - downloader::download_file_with_progress(task, &client, move |file_downloaded| { - // file_downloaded 是该文件的累计下载大小 - // 计算当前总下载大小 = 文件开始时的总量 + 该文件当前已下载大小 - let total_downloaded = file_start_downloaded + file_downloaded; - - // 使用 compare_and_swap 确保只更新更大的值(避免并发问题) - let current = downloaded_size_clone.load(std::sync::atomic::Ordering::Relaxed); - if total_downloaded > current { - downloaded_size_clone - .store(total_downloaded, std::sync::atomic::Ordering::Relaxed); - } - - // 重新加载确保使用最新的值 - let final_downloaded = - downloaded_size_clone.load(std::sync::atomic::Ordering::Relaxed); - - // 计算进度百分比 - let percentage = if total_size_clone > 0 { - (final_downloaded as f64 / total_size_clone as f64) * 100.0 - } else { - 0.0 - }; - - // 发送进度事件 - let progress_event = UpdateEvent::DownloadProgress { - payload: crate::infrastructure::updater::types::DownloadProgressPayload { - downloaded: final_downloaded, - total: total_size_clone, - percentage, - }, - }; - service::emit_event(&app_handle_clone, progress_event); - - Ok(()) - }) - .await; - - match download_result { - Ok(_) => { - // 下载成功,静默处理 - } - Err(e) => { - log::error!("下载失败: {} - {}", resource_name, e); - failed_count += 1; - // 清理临时文件 - let _ = fs::remove_file(&task.temp_path); - continue; - } - } - - // 2. 校验阶段 - match verifier::verify_file_hash(task) { - Ok(_) => { - // 转换为安装任务 - install_tasks.push(service::update_task_to_install_task(task)); - success_count += 1; - } - Err(e) => { - log::error!("校验失败: {} - {}", resource_name, e); - failed_count += 1; - // 清理临时文件 - let _ = fs::remove_file(&task.temp_path); - continue; - } - } - } - - let download_duration = download_start.elapsed(); - - // 3. 保存安装任务到文件 - let tasks_file_path = if !install_tasks.is_empty() { - let file_path = service::save_install_tasks(&install_tasks)?; - Some(file_path.to_string_lossy().to_string()) - } else { - None - }; - - // 4. 发送结果事件 - if failed_count == 0 { - log::info!( - "下载完成,共 {} 个文件,总耗时: {:.2} 秒", - success_count, - download_duration.as_secs_f64() - ); - - if let Some(ref tasks_file) = tasks_file_path { - service::emit_event( - &app_handle, - UpdateEvent::DownloadComplete { - payload: DownloadCompletePayload { - tasks_file: tasks_file.clone(), - success_count, - }, - }, - ); - } - - Ok(DownloadResult { - has_updates: true, - tasks_file: tasks_file_path, - install_strategy: InstallStrategy::DirectReplace, - success_count, - }) - } else if success_count > 0 { - log::warn!( - "部分下载完成:成功 {}, 失败 {},总耗时: {:.2} 秒", - success_count, - failed_count, - download_duration.as_secs_f64() - ); - - // 部分成功,也通知前端 - if let Some(ref tasks_file) = tasks_file_path { - service::emit_event( - &app_handle, - UpdateEvent::DownloadPartial { - payload: DownloadPartialPayload { - tasks_file: tasks_file.clone(), - success_count, - failed_count, - error_message: format!( - "部分下载完成:成功 {}, 失败 {}", - success_count, failed_count - ), - }, - }, - ); - } - - Ok(DownloadResult { - has_updates: true, - tasks_file: tasks_file_path, - install_strategy: InstallStrategy::DirectReplace, - success_count, - }) - } else { - log::error!( - "所有下载失败,总耗时: {:.2} 秒", - download_duration.as_secs_f64() - ); - - service::emit_error_event(&app_handle, 3, format!("{} 个下载全部失败", failed_count)); - - Err(format!("所有下载失败: {} 个任务", failed_count).into()) - } - } - - /// 启动更新安装并退出主程序 - /// - /// # 参数 - /// - `app_handle`: Tauri 应用句柄 - /// - /// # 说明 - /// 启动 updater.exe install 命令,然后退出主程序 - /// 任务文件路径由统一路径层提供(update_tasks.json) - pub async fn start_update_install(_app_handle: AppHandle) -> Result<()> { - if let Some(installer_path) = service::take_installer_package_path().await { - return launch_installer_update(PathBuf::from(installer_path)).await; - } - - let tasks_file_path = service::get_tasks_file_path()?; - Self::start_update_install_with_tasks_file(tasks_file_path).await - } - - pub async fn start_update_install_with_tasks_file(tasks_file_path: PathBuf) -> Result<()> { - // 1. 检查任务文件是否存在 - if !tasks_file_path.exists() { - return Err(format!("任务文件不存在: {}", tasks_file_path.display()).into()); - } - - // 2. 获取 updater.exe 所在目录 - let exe_dir = service::get_exe_directory()?; - - // 3. 构建 updater.exe 路径 - let updater_exe = exe_dir.join("updater.exe"); - - if !updater_exe.exists() { - log::error!("找不到 updater.exe: {}", updater_exe.display()); - return Err("UPDATER_NOT_FOUND".into()); - } - - // 4. 启动 updater.exe install - #[cfg(target_os = "windows")] - { - use std::process::Stdio; - use tokio::process::Command; - const CREATE_NO_WINDOW: u32 = 0x08000000; - - Command::new(&updater_exe) - .arg("install") - .arg(tasks_file_path.to_string_lossy().to_string()) - .creation_flags(CREATE_NO_WINDOW) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| { - log::error!("启动 updater.exe 失败: {}", e); - if e.to_string().contains("740") { - "UPDATER_NO_PERMISSION" - } else { - "UPDATER_START_FAILED" - } - })?; - } - - #[cfg(not(target_os = "windows"))] - { - use std::process::Stdio; - use tokio::process::Command; - - Command::new(&updater_exe) - .arg("install") - .arg(tasks_file_path.to_string_lossy().to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn()?; - } - - // 5. 延迟后退出主程序(给 updater 一点时间启动) - tokio::spawn(async { - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - std::process::exit(0); - }); - - Ok(()) - } - - async fn download_installer_package(app_handle: AppHandle) -> Result { - service::emit_event( - &app_handle, - UpdateEvent::Downloading { - payload: crate::infrastructure::updater::types::DownloadingPayload { - update_count: 1, - }, - }, - ); - - let latest_release = fetch_latest_release().await?; - let platform = latest_release - .platforms - .get("x86_64-pc-windows-msvc") - .ok_or("latest.json 中缺少 windows 平台信息")?; - let installer_url = platform.r2_url.trim(); - - if installer_url.is_empty() { - return Err("latest.json 中缺少 windows.r2_url".into()); - } - - let installer_path = download_installer_file(&app_handle, installer_url).await?; - let installer_path_string = installer_path.to_string_lossy().to_string(); - - service::store_installer_package_path(installer_path_string.clone()).await; - - service::emit_event( - &app_handle, - UpdateEvent::DownloadComplete { - payload: DownloadCompletePayload { - tasks_file: installer_path_string.clone(), - success_count: 1, - }, - }, - ); - - Ok(DownloadResult { - has_updates: true, - tasks_file: Some(installer_path_string), - install_strategy: InstallStrategy::InstallerPackage, - success_count: 1, - }) - } -} - -fn should_use_installer_package( - tasks: &[crate::infrastructure::updater::types::UpdateTask], -) -> bool { - tasks.iter().any(|task| !is_target_parent_writable(&task.target_path)) -} - -fn is_target_parent_writable(target_path: &Path) -> bool { - let Some(parent) = target_path.parent() else { - return false; - }; - - if !parent.exists() { - return false; - } - - let probe_name = format!(".simprint-write-test-{}", uuid::Uuid::new_v4()); - let probe_path = parent.join(probe_name); - - match fs::write(&probe_path, b"probe") { - Ok(_) => { - let _ = fs::remove_file(&probe_path); - true - } - Err(_) => false, - } -} - -async fn fetch_latest_release() -> Result { - let ctx = crate::app::context::AppContext::get(); - let response = Client::builder() - .timeout(Duration::from_secs(30)) - .build()? - .get(&ctx.config.updater.latest_json_url) - .send() - .await?; - - let status = response.status(); - if !status.is_success() { - return Err(format!("latest.json 请求失败: {}", status).into()); - } - - Ok(response.json::().await?) -} - -async fn download_installer_file(app_handle: &AppHandle, installer_url: &str) -> Result { - let client = Client::builder().timeout(Duration::from_secs(300)).build()?; - - let response = client.get(installer_url).send().await?; - - if !response.status().is_success() { - return Err(format!("下载安装包失败: {}", response.status()).into()); - } - - let total = response.content_length().unwrap_or(0); - let file_name = installer_file_name(installer_url); - let installer_dir = crate::core::paths::PathManager::get_updater_dir()?.join("installer"); - fs::create_dir_all(&installer_dir)?; - let installer_path = installer_dir.join(file_name); - - let mut file = std::fs::File::create(&installer_path)?; - let mut stream = response.bytes_stream(); - let mut downloaded = 0u64; - - use futures::StreamExt; - use std::io::Write; - - while let Some(chunk) = stream.next().await { - let chunk = chunk?; - file.write_all(&chunk)?; - downloaded += chunk.len() as u64; - - let percentage = if total > 0 { - (downloaded as f64 / total as f64) * 100.0 - } else { - 0.0 - }; - - service::emit_event( - app_handle, - UpdateEvent::DownloadProgress { - payload: crate::infrastructure::updater::types::DownloadProgressPayload { - downloaded, - total, - percentage, - }, - }, - ); - } - - file.sync_all()?; - Ok(installer_path) -} - -fn installer_file_name(installer_url: &str) -> String { - reqwest::Url::parse(installer_url) - .ok() - .and_then(|url| { - url.path_segments().and_then(|segments| segments.last().map(|v| v.to_string())) - }) - .filter(|name| !name.trim().is_empty()) - .unwrap_or_else(|| "simprint_setup.exe".to_string()) -} - -async fn launch_installer_update(installer_path: PathBuf) -> Result<()> { - if !installer_path.exists() { - return Err(format!("安装包不存在: {}", installer_path.display()).into()); - } - - #[cfg(target_os = "windows")] - { - let exe_dir = service::get_exe_directory()?; - let updater_exe = exe_dir.join("updater.exe"); - - if !updater_exe.exists() { - return Err(format!("找不到 updater.exe: {}", updater_exe.display()).into()); - } - - launch_elevated_updater_for_installer(&updater_exe, &installer_path)?; - } - - #[cfg(not(target_os = "windows"))] - { - let _ = installer_path; - return Err("当前平台不支持安装包升级分支".into()); - } - - tokio::spawn(async { - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - std::process::exit(0); - }); - - Ok(()) -} - -#[cfg(target_os = "windows")] -fn launch_elevated_updater_for_installer(updater_exe: &Path, installer_path: &Path) -> Result<()> { - use std::ffi::OsStr; - use std::os::windows::ffi::OsStrExt; - use windows::Win32::UI::Shell::ShellExecuteW; - use windows::Win32::UI::WindowsAndMessaging::SW_SHOWDEFAULT; - use windows::core::PCWSTR; - - fn to_wide(value: &OsStr) -> Vec { - value.encode_wide().chain(std::iter::once(0)).collect() - } - - let verb = to_wide(OsStr::new("runas")); - let file = to_wide(updater_exe.as_os_str()); - let params = to_wide(OsStr::new(&format!( - "install-package \"{}\"", - installer_path.display() - ))); - - let result = unsafe { - ShellExecuteW( - None, - PCWSTR(verb.as_ptr()), - PCWSTR(file.as_ptr()), - PCWSTR(params.as_ptr()), - PCWSTR::null(), - SW_SHOWDEFAULT, - ) - }; - - let code = result.0 as isize; - if code <= 32 { - return Err(format!("启动安装包失败: ShellExecuteW 返回 {}", code).into()); - } - - Ok(()) -} diff --git a/src-tauri/src/services/window/mod.rs b/src-tauri/src/services/window/mod.rs index af4d84cb..e0b9c3e0 100644 --- a/src-tauri/src/services/window/mod.rs +++ b/src-tauri/src/services/window/mod.rs @@ -255,12 +255,15 @@ impl WindowService { } /// 创建主窗口 - pub async fn create_main_window(app_handle: &AppHandle) -> Result<()> { + pub fn create_main_window(app_handle: &AppHandle) -> Result<()> { if app_handle.get_webview_window("main").is_some() { log::debug!("主窗口已存在,跳过创建"); return Ok(()); } + let build_started_at = std::time::Instant::now(); + log::info!("Main window build started"); + #[cfg(feature = "production")] let devtools_enabled = false; #[cfg(not(feature = "production"))] @@ -282,33 +285,12 @@ impl WindowService { .devtools(devtools_enabled) .build()?; - log::trace!("主窗口已创建"); - Ok(()) - } - - /// 创建启动加载窗口 - pub fn create_splashscreen_window(app_handle: &AppHandle) -> Result<()> { - if app_handle.get_webview_window("splashscreen").is_some() { - log::debug!("启动窗口已存在,跳过创建"); - return Ok(()); - } + log::info!( + "Main window built in {:.1} ms", + build_started_at.elapsed().as_secs_f64() * 1_000.0 + ); - let _window = WebviewWindowBuilder::new( - app_handle, - "splashscreen", - WebviewUrl::App("splashscreen.html".into()), - ) - .data_directory(Self::get_webview_data_dir()?) - .title("Simprint") - .decorations(false) - .inner_size(725.0, 475.0) - .resizable(false) - .center() - .visible(false) - .drag_and_drop(false) - .build()?; - - log::trace!("启动窗口已创建"); + log::trace!("主窗口已创建"); Ok(()) } diff --git a/src-tauri/tauri.conf.window.download.json b/src-tauri/tauri.conf.embed-bootstrapper.json similarity index 61% rename from src-tauri/tauri.conf.window.download.json rename to src-tauri/tauri.conf.embed-bootstrapper.json index f3f98b0c..96014327 100644 --- a/src-tauri/tauri.conf.window.download.json +++ b/src-tauri/tauri.conf.embed-bootstrapper.json @@ -1,67 +1,72 @@ -{ - "$schema": "https://schema.tauri.app/config/2", - "productName": "T9Battle", - "version": "0.5.368", - "identifier": "com.t9.csgo.lius.app", - "build": { - "beforeDevCommand": "cd ../client-frontend && pnpm run dev", - "devUrl": "http://localhost:1420", - "beforeBuildCommand": "cd ../client-frontend && node build.cjs", - "frontendDist": "../client-frontend/dist" - }, - "app": { - "windows": [], - "security": { - "csp": null - }, - "withGlobalTauri": true - }, - "bundle": { - "active": true, - "targets": "nsis", - "icon": [ - "icons/Square284x284Logo.png", - "icons/icon.icns", - "icons/icon.ico" - ], - "windows": { - "allowDowngrades": true, - "certificateThumbprint": null, - "digestAlgorithm": null, - "signCommand": null, - "timestampUrl": null, - "tsp": false, - "webviewInstallMode": { - "silent": true, - "type": "downloadBootstrapper" - }, - "wix": { - "language": "zh-CN" - }, - "nsis": { - "displayLanguageSelector": true, - "installMode": "currentUser", - "installerIcon": "icons/icon.ico", - "installerHooks": "./windows/nsis/installer-hooks.nsi", - "template": "./windows/nsis/installer.nsi", - "sidebarImage": "./windows/assets/sidebarImage.bmp", - "startMenuFolder": "Simprint", - "languages": [ - "English", - "SimpChinese" - ] - } - } - }, - "plugins": { - "deep-link": { - "desktop": [ - { - "schemes": [ - "simprint" - ] - } - ] - } - } -} +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Simprint", + "version": "0.1.0", + "identifier": "com.lius.simprint", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://localhost:5173", + "beforeBuildCommand": "node build.cjs", + "frontendDist": "../dist" + }, + "app": { + "windows": [], + "security": { + "csp": null + }, + "withGlobalTauri": true + }, + "bundle": { + "active": true, + "createUpdaterArtifacts": true, + "targets": "nsis", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "windows": { + "allowDowngrades": true, + "certificateThumbprint": null, + "digestAlgorithm": null, + "signCommand": null, + "timestampUrl": null, + "tsp": false, + "webviewInstallMode": { + "silent": true, + "type": "embedBootstrapper" + }, + "wix": { + "language": "zh-CN" + }, + "nsis": { + "displayLanguageSelector": true, + "installMode": "currentUser", + "installerIcon": "icons/icon.ico", + "installerHooks": "./windows/nsis/installer-hooks.nsi", + "template": "./windows/nsis/installer.nsi", + "sidebarImage": "./windows/assets/sidebarImage.bmp", + "startMenuFolder": "Simprint", + "languages": ["English", "SimpChinese"] + } + } + }, + "plugins": { + "updater": { + "endpoints": ["https://github.com/Simprint/simprint/releases/latest/download/latest.json"], + "pubkey": "", + "windows": { + "installMode": "passive" + } + }, + "deep-link": { + "desktop": [ + { + "schemes": ["simprint"] + } + ] + } + } +} diff --git a/src-tauri/tauri.conf.fixed.json b/src-tauri/tauri.conf.fixed-runtime.json similarity index 81% rename from src-tauri/tauri.conf.fixed.json rename to src-tauri/tauri.conf.fixed-runtime.json index 8bd20713..f52ddce1 100644 --- a/src-tauri/tauri.conf.fixed.json +++ b/src-tauri/tauri.conf.fixed-runtime.json @@ -1,67 +1,74 @@ -{ - "$schema": "https://schema.tauri.app/config/2", - "productName": "Simprint", - "version": "0.1.0", - "identifier": "com.lius.simprint", - "build": { - "beforeDevCommand": "pnpm dev", - "devUrl": "http://localhost:5173", - "beforeBuildCommand": "node build.cjs", - "frontendDist": "../dist" +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Simprint", + "version": "0.1.0", + "identifier": "com.lius.simprint", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://localhost:5173", + "beforeBuildCommand": "node build.cjs", + "frontendDist": "../dist" }, "app": { "windows": [], "security": { "csp": null }, - "withGlobalTauri": true - }, + "withGlobalTauri": true + }, "bundle": { "active": true, + "createUpdaterArtifacts": true, "targets": "nsis", "icon": [ "icons/32x32.png", "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ], - "windows": { - "allowDowngrades": true, - "certificateThumbprint": null, - "digestAlgorithm": null, - "signCommand": null, - "timestampUrl": null, - "tsp": false, - "webviewInstallMode": { - "type": "fixedRuntime", - "path": "./webview-fixed/Microsoft.WebView2.FixedVersionRuntime.144.0.3719.93.x64/" - }, - "wix": { - "language": "zh-CN" - }, + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "windows": { + "allowDowngrades": true, + "certificateThumbprint": null, + "digestAlgorithm": null, + "signCommand": null, + "timestampUrl": null, + "tsp": false, + "webviewInstallMode": { + "type": "fixedRuntime", + "path": "./webview-fixed/Microsoft.WebView2.FixedVersionRuntime.151.0.4129.78.x64/" + }, + "wix": { + "language": "zh-CN" + }, "nsis": { "displayLanguageSelector": true, "installMode": "currentUser", "installerIcon": "icons/icon.ico", "installerHooks": "./windows/nsis/installer-hooks.nsi", "template": "./windows/nsis/installer.nsi", - "sidebarImage": "./windows/assets/sidebarImage.bmp", - "startMenuFolder": "Simprint", - "languages": [ - "English", - "SimpChinese" - ] - } - } - }, - "plugins": { - "deep-link": { - "desktop": [ - { - "schemes": ["simprint"] - } - ] - } - } -} + "sidebarImage": "./windows/assets/sidebarImage.bmp", + "startMenuFolder": "Simprint", + "languages": ["English", "SimpChinese"] + } + } + }, + "plugins": { + "updater": { + "endpoints": [ + "https://github.com/Simprint/simprint/releases/latest/download/latest-fixed.json" + ], + "pubkey": "", + "windows": { + "installMode": "passive" + } + }, + "deep-link": { + "desktop": [ + { + "schemes": ["simprint"] + } + ] + } + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 78bd8c5f..cdf8cb79 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,14 +1,14 @@ -{ - "$schema": "https://schema.tauri.app/config/2", - "productName": "Simprint", - "version": "0.1.0", - "identifier": "com.lius.simprint", - "build": { - "beforeDevCommand": "pnpm dev", - "devUrl": "http://localhost:5173", - "beforeBuildCommand": "node build.cjs", - "frontendDist": "../dist" - }, +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Simprint", + "version": "0.1.0", + "identifier": "com.lius.simprint", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://localhost:5173", + "beforeBuildCommand": "node build.cjs", + "frontendDist": "../dist" + }, "app": { "windows": [], "security": { @@ -18,52 +18,62 @@ }, "bundle": { "active": true, + "createUpdaterArtifacts": true, "targets": "nsis", "icon": [ "icons/32x32.png", "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ], - "windows": { - "allowDowngrades": true, - "certificateThumbprint": null, - "digestAlgorithm": null, - "signCommand": null, - "timestampUrl": null, - "tsp": false, - "webviewInstallMode": { - "silent": true, - "type": "downloadBootstrapper" - }, - "wix": { - "language": "zh-CN" - }, + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "windows": { + "allowDowngrades": true, + "certificateThumbprint": null, + "digestAlgorithm": null, + "signCommand": null, + "timestampUrl": null, + "tsp": false, + "webviewInstallMode": { + "silent": true, + "type": "embedBootstrapper" + }, + "wix": { + "language": "zh-CN" + }, "nsis": { "displayLanguageSelector": true, "installMode": "currentUser", "installerIcon": "icons/icon.ico", "installerHooks": "./windows/nsis/installer-hooks.nsi", "template": "./windows/nsis/installer.nsi", - "sidebarImage": "./windows/assets/sidebarImage.bmp", - "startMenuFolder": "Simprint", - "languages": [ - "English", - "SimpChinese" - ] - } - } - }, - "plugins": { - "deep-link": { - "desktop": [ - { - "schemes": [ - "simprint" - ] - } - ] - } - } + "sidebarImage": "./windows/assets/sidebarImage.bmp", + "startMenuFolder": "Simprint", + "languages": [ + "English", + "SimpChinese" + ] + } + } + }, + "plugins": { + "updater": { + "endpoints": [ + "https://github.com/Simprint/simprint/releases/latest/download/latest.json" + ], + "pubkey": "", + "windows": { + "installMode": "passive" + } + }, + "deep-link": { + "desktop": [ + { + "schemes": [ + "simprint" + ] + } + ] + } + } } diff --git a/src-tauri/windows/nsis/installer-hooks.nsi b/src-tauri/windows/nsis/installer-hooks.nsi index f4f5504e..658e735d 100644 --- a/src-tauri/windows/nsis/installer-hooks.nsi +++ b/src-tauri/windows/nsis/installer-hooks.nsi @@ -104,10 +104,6 @@ FunctionEnd Push $RuntimePathValue Call un.RemoveRuntimePathIfSafe - ReadRegStr $RuntimePathValue HKCU "Software\${PRODUCTNAME}\RuntimePaths" "UpdaterDir" - Push $RuntimePathValue - Call un.RemoveRuntimePathIfSafe - ReadRegStr $RuntimePathValue HKCU "Software\${PRODUCTNAME}\RuntimePaths" "ConfigDir" Push $RuntimePathValue Call un.RemoveRuntimePathIfSafe diff --git a/src-tauri/windows/updater.manifest b/src-tauri/windows/updater.manifest deleted file mode 100644 index 921435ef..00000000 --- a/src-tauri/windows/updater.manifest +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/src-tauri/windows/updater.rc b/src-tauri/windows/updater.rc deleted file mode 100644 index d1e7c987..00000000 --- a/src-tauri/windows/updater.rc +++ /dev/null @@ -1,2 +0,0 @@ -1 RT_MANIFEST updater.manifest - diff --git a/src/App.tsx b/src/App.tsx index 3cb6e864..04a0d8ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,66 +1,136 @@ -import React, { useEffect } from 'react'; -import { AppLayout } from './app/components/AppLayout'; -import { pluginRegistry, pluginLoader, setPluginImportFunctions } from '@slotkitjs/core'; - -// Import generated plugin import mappings -// This file is generated by 'slotkit generate-imports' -import * as pluginImports from './core/plugin/loader/plugin-imports.generated'; - -// Initialize plugin import mappings -if (pluginImports.getPluginImport && pluginImports.getAvailablePluginIds) { - setPluginImportFunctions({ - getPluginImport: (pluginId: string) => { - const importFn = pluginImports.getPluginImport(pluginId); - if (!importFn) return undefined; - return () => Promise.resolve(importFn()); - }, - getAvailablePluginIds: pluginImports.getAvailablePluginIds, - getAllPluginManifests: pluginImports.getAllPluginManifests, - getPluginManifest: pluginImports.getPluginManifest, - }); -} else { - console.warn('[WARN] Plugin import functions not found in generated file'); -} - -// Load all plugins -const loadPlugins = async () => { - try { - const plugins = await pluginLoader.loadAllPlugins(); - - plugins.forEach((plugin) => { - pluginRegistry.register(plugin); - }); - - return plugins; - } catch (error) { - console.error('[ERROR] Failed to load plugins:', error); - return []; - } -}; - -interface AppProps { - /** - * 应用模式:'main' 渲染主应用布局,'splashscreen' 渲染启动屏幕,'syncer' 渲染同步器窗口 - */ - mode?: 'main' | 'splashscreen' | 'syncer'; -} - -const App: React.FC = ({ mode = 'main' }) => { - useEffect(() => { - loadPlugins() - .then(() => { - console.log('[App] Plugins loaded for mode:', mode); - }) - .catch((error) => { - console.error('[ERROR] Failed to load plugins:', error); - }); - }, [mode]); - - return ( -
- -
- ); -}; - -export default App; +import React, { useEffect, useState } from 'react'; +import { AppLayout } from './app/components/AppLayout'; +import { pluginRegistry, pluginLoader, setPluginImportFunctions } from '@slotkitjs/core'; + +// Import generated plugin import mappings +// This file is generated by 'slotkit generate-imports' +import * as pluginImports from './core/plugin/loader/plugin-imports.generated'; + +const MAIN_STARTUP_PLUGIN_IDS = [ + 'app-layout', + 'authorization-layout', + 'environment-manager', + 'login', + 'register', + 'i18n', + 'store', + 'window-manager', +] as const; + +const MODE_PLUGIN_IDS = { + main: MAIN_STARTUP_PLUGIN_IDS, + syncer: ['syncer'], +} as const; + +const MAIN_WINDOW_READY_EVENT = 'simprint:main-window-ready'; + +// Initialize plugin import mappings +if (pluginImports.getPluginImport && pluginImports.getAvailablePluginIds) { + setPluginImportFunctions({ + // @deprecated Compatibility adapter for generated mappings from + // @slotkitjs/core < 0.3.5. Remove after all consumers generate Promise-based imports. + getPluginImport: (pluginId: string) => { + const importPlugin = pluginImports.getPluginImport(pluginId); + return importPlugin ? () => Promise.resolve(importPlugin()) : undefined; + }, + getAvailablePluginIds: pluginImports.getAvailablePluginIds, + getAllPluginManifests: pluginImports.getAllPluginManifests, + getPluginManifest: pluginImports.getPluginManifest, + }); +} else { + console.warn('[WARN] Plugin import functions not found in generated file'); +} + +const loadPlugins = async (pluginIds: readonly string[]) => { + try { + const selectivePluginLoader = pluginLoader as typeof pluginLoader & { + loadPlugins?: (ids: readonly string[]) => ReturnType; + }; + + // @deprecated Compatibility fallback for @slotkitjs/core < 0.3.5. + // Remove after Simprint's lockfile is upgraded to a published 0.3.5+ release. + const plugins = selectivePluginLoader.loadPlugins + ? await selectivePluginLoader.loadPlugins(pluginIds) + : await pluginLoader.loadAllPlugins(); + + plugins.forEach((plugin) => { + if (!pluginRegistry.getPlugin(plugin.id)) { + pluginRegistry.register(plugin); + } + }); + + return plugins; + } catch (error) { + console.error('[ERROR] Failed to load plugins:', error); + return []; + } +}; + +interface AppProps { + /** + * 应用模式:'main' 渲染主应用布局,'syncer' 渲染同步器窗口 + */ + mode?: 'main' | 'syncer'; +} + +const App: React.FC = ({ mode = 'main' }) => { + const [pluginsReady, setPluginsReady] = useState(false); + + useEffect(() => { + let cancelled = false; + let idleCallbackId: number | undefined; + + const loadDeferredPlugins = () => { + const startupPlugins = new Set(MAIN_STARTUP_PLUGIN_IDS); + const deferredPluginIds = pluginImports + .getAvailablePluginIds() + .filter((pluginId) => !startupPlugins.has(pluginId) && pluginId !== 'syncer'); + + const startLoading = () => { + void loadPlugins(deferredPluginIds).then(() => { + console.log('[App] Deferred plugins loaded'); + }); + }; + + if ('requestIdleCallback' in window) { + idleCallbackId = window.requestIdleCallback(startLoading, { timeout: 2000 }); + } else { + idleCallbackId = window.setTimeout(startLoading, 0); + } + }; + + if (mode === 'main') { + window.addEventListener(MAIN_WINDOW_READY_EVENT, loadDeferredPlugins, { once: true }); + } + + loadPlugins(MODE_PLUGIN_IDS[mode]) + .then(() => { + console.log('[App] Plugins loaded for mode:', mode); + if (!cancelled) setPluginsReady(true); + }) + .catch((error) => { + console.error('[ERROR] Failed to load plugins:', error); + if (!cancelled) setPluginsReady(true); + }); + + return () => { + cancelled = true; + window.removeEventListener(MAIN_WINDOW_READY_EVENT, loadDeferredPlugins); + if (idleCallbackId !== undefined) { + if ('cancelIdleCallback' in window) { + window.cancelIdleCallback(idleCallbackId); + } else { + window.clearTimeout(idleCallbackId); + } + } + }; + }, [mode]); + + return ( +
+ +
+ ); +}; + +export default App; diff --git a/src/app/components/AppLayout/index.tsx b/src/app/components/AppLayout/index.tsx index d6cbffd2..8e8bd6ae 100644 --- a/src/app/components/AppLayout/index.tsx +++ b/src/app/components/AppLayout/index.tsx @@ -1,47 +1,68 @@ // 确保扩展点在所有插件导入之前注册 import '../../extension-points'; -import React, { useEffect, useState } from 'react'; +import React, { Suspense, useCallback, useEffect, useState } from 'react'; import { BrowserRouter } from 'react-router'; import { pluginRegistry } from '@slotkitjs/core'; -import { useAuthStore } from '../../../../plugins/services/store/src'; -import { SettingsDialog } from '../../../../plugins/pages/system-settings/src'; +import { useAuthStore, useSettingsDialogStore } from '../../../../plugins/services/store/src'; import { SettingsBootstrap } from '../../../../plugins/services/store/src'; import { ThemeProvider } from '@/components/theme-provider'; import { I18nProvider } from '@/components/i18n-provider'; import { commonResources } from '@/i18n/resources/common'; import { Toaster } from '@/components/ui/sonner'; import { AppRoutes } from '../AppRoutes'; -import { SplashscreenRenderer } from '../SplashscreenRenderer'; import { SyncerRenderer } from '../SyncerRenderer'; import { useDisableDevTools } from '@/hooks/use-disable-dev-tools'; import { SessionLockOverlay } from '../session-lock-overlay'; import { useSessionLock } from '../../hooks/use-session-lock'; +const SettingsDialog = React.lazy(() => + import('../../../../plugins/pages/system-settings/src/components/settings-dialog').then( + (module) => ({ default: module.SettingsDialog }) + ) +); + interface AppLayoutProps { /** - * 布局模式:'main' 渲染主应用布局,'splashscreen' 渲染启动屏幕,'syncer' 渲染同步器窗口 + * 布局模式:'main' 渲染主应用布局,'syncer' 渲染同步器窗口 */ - mode?: 'main' | 'splashscreen' | 'syncer'; + mode?: 'main' | 'syncer'; + pluginsReady?: boolean; } -export const AppLayout: React.FC = ({ mode = 'main' }) => { +export const AppLayout: React.FC = ({ mode = 'main', pluginsReady = false }) => { const [windowManagerComponent, setWindowManagerComponent] = useState( null ); const { initAuth, isAuthenticated } = useAuthStore(); + const isSettingsDialogOpen = useSettingsDialogStore((state) => state.isOpen); + const [authReady, setAuthReady] = useState(mode !== 'main'); + const [routesReady, setRoutesReady] = useState(false); useDisableDevTools(); const sessionLock = useSessionLock(mode === 'main'); // 初始化认证状态(仅在 main 模式下) useEffect(() => { if (mode === 'main') { - initAuth().catch((error) => { - console.error('[AppLayout] 初始化认证状态失败:', error); - }); + let cancelled = false; + initAuth() + .catch((error) => { + console.error('[AppLayout] 初始化认证状态失败:', error); + }) + .finally(() => { + if (!cancelled) setAuthReady(true); + }); + + return () => { + cancelled = true; + }; } }, [mode, initAuth]); + const handleRoutesReady = useCallback(() => { + setRoutesReady(true); + }, []); + // 监听插件注册,确保 window-manager 插件加载后被渲染 useEffect(() => { const checkWindowManager = () => { @@ -67,11 +88,6 @@ export const AppLayout: React.FC = ({ mode = 'main' }) => { }; }, []); - // 如果是 splashscreen 模式,渲染启动屏幕 - if (mode === 'splashscreen') { - return ; - } - // 如果是 syncer 模式,渲染同步器窗口 if (mode === 'syncer') { return ( @@ -86,18 +102,24 @@ export const AppLayout: React.FC = ({ mode = 'main' }) => { - - {windowManagerComponent && React.createElement(windowManagerComponent)} - - - - - + + {pluginsReady && authReady && routesReady && windowManagerComponent + ? React.createElement(windowManagerComponent) + : null} + + {isSettingsDialogOpen ? ( + + + + ) : null} + + + diff --git a/src/app/components/AppRoutes/index.tsx b/src/app/components/AppRoutes/index.tsx index bbc8ad21..ecfaae6a 100644 --- a/src/app/components/AppRoutes/index.tsx +++ b/src/app/components/AppRoutes/index.tsx @@ -1,56 +1,74 @@ -import { useEffect, useState } from 'react'; -import { useRoutes } from 'react-router'; -import { extensionRegistry, pluginRegistry } from '@slotkitjs/core'; -import { useRouteConfig } from '../../hooks/useRouteConfig'; -import type { RouteConfig } from '../../types'; - -/** - * 应用路由组件 - * 负责动态加载和管理路由 - */ -export const AppRoutes: React.FC = () => { - const [routes, setRoutes] = useState([]); - - useEffect(() => { - const checkLayout = () => { - const layoutPlugin = pluginRegistry.getPlugin('app-layout'); - // 检查布局插件是否已加载(用于调试) - if (layoutPlugin?.component) { - console.log('[AppRoutes] Layout plugin loaded'); - } - }; - - const updateRoutes = () => { - const routeContributions = extensionRegistry.getContributions('routes'); - const newRoutes = routeContributions.map((c) => c.value); - console.log( - '[AppRoutes] Routes updated:', - newRoutes.map((r) => r.path) - ); - setRoutes(newRoutes); - }; - - checkLayout(); - updateRoutes(); - - // 订阅插件注册事件 - const unsubscribe = pluginRegistry.subscribe((event) => { - if (event.type === 'register') { - if (event.plugin?.id === 'app-layout') { - console.log('[AppRoutes] Layout plugin registered'); - } - // 当插件注册时,更新路由(插件可能在模块加载时已贡献路由) - updateRoutes(); - checkLayout(); - } - }); - - return () => { - unsubscribe(); - }; - }, []); - - const routeElements = useRouteConfig(routes); - - return useRoutes(routeElements); -}; +import { useEffect, useState } from 'react'; +import { useRoutes } from 'react-router'; +import { extensionRegistry, pluginRegistry } from '@slotkitjs/core'; +import { useRouteConfig } from '../../hooks/useRouteConfig'; +import type { RouteConfig } from '../../types'; + +interface AppRoutesProps { + onReady?: () => void; +} + +/** + * 应用路由组件 + * 负责动态加载和管理路由 + */ +export const AppRoutes: React.FC = ({ onReady }) => { + const [routes, setRoutes] = useState([]); + + useEffect(() => { + const checkLayout = () => { + const layoutPlugin = pluginRegistry.getPlugin('app-layout'); + // 检查布局插件是否已加载(用于调试) + if (layoutPlugin?.component) { + console.log('[AppRoutes] Layout plugin loaded'); + } + }; + + const updateRoutes = () => { + const routeContributions = extensionRegistry.getContributions('routes'); + const newRoutes = routeContributions.map((c) => c.value); + console.log( + '[AppRoutes] Routes updated:', + newRoutes.map((r) => r.path) + ); + setRoutes(newRoutes); + }; + + checkLayout(); + updateRoutes(); + + // 订阅插件注册事件 + const unsubscribe = pluginRegistry.subscribe((event) => { + if (event.type === 'register') { + if (event.plugin?.id === 'app-layout') { + console.log('[AppRoutes] Layout plugin registered'); + } + // 当插件注册时,更新路由(插件可能在模块加载时已贡献路由) + updateRoutes(); + checkLayout(); + } + }); + + return () => { + unsubscribe(); + }; + }, []); + + const routeElements = useRouteConfig(routes); + + useEffect(() => { + if (routes.length === 0 || !onReady) return; + + let secondFrame = 0; + const firstFrame = requestAnimationFrame(() => { + secondFrame = requestAnimationFrame(onReady); + }); + + return () => { + cancelAnimationFrame(firstFrame); + if (secondFrame) cancelAnimationFrame(secondFrame); + }; + }, [routes, routeElements, onReady]); + + return useRoutes(routeElements); +}; diff --git a/src/app/components/SplashscreenRenderer/index.tsx b/src/app/components/SplashscreenRenderer/index.tsx deleted file mode 100644 index f5693055..00000000 --- a/src/app/components/SplashscreenRenderer/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { PluginRenderer } from '../PluginRenderer'; - -export const SplashscreenRenderer: React.FC = () => { - return ( - - } - /> - ); -}; diff --git a/src/app/components/session-lock-overlay.tsx b/src/app/components/session-lock-overlay.tsx index e0f4ea27..b27cb147 100644 --- a/src/app/components/session-lock-overlay.tsx +++ b/src/app/components/session-lock-overlay.tsx @@ -35,7 +35,8 @@ export const SessionLockOverlay: React.FC = ({ return null; } - const displayName = user?.nickname || user?.email || t('sessionLock.defaultUser'); + const displayName = user?.nickname || t('sessionLock.defaultUser'); + const hasPassword = user?.has_password ?? false; const handleUnlock = async () => { await onUnlock(password); @@ -59,7 +60,13 @@ export const SessionLockOverlay: React.FC = ({