diff --git a/.gitea/workflows/auto-merge.yml b/.gitea/workflows/auto-merge.yml new file mode 100644 index 000000000..d4a29622a --- /dev/null +++ b/.gitea/workflows/auto-merge.yml @@ -0,0 +1,21 @@ +name: Auto Merge PRs + +on: + schedule: + - cron: '0 */6 * * *' + workflow_dispatch: + +jobs: + auto-merge: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Auto merge develop PRs + run: | + bash scripts/auto_merge_prs.sh develop + + - name: Auto merge main PRs (release only) + run: | + bash scripts/auto_merge_prs.sh main diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 7939540c7..7187a322c 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -22,34 +22,31 @@ jobs: steps: - name: Checkout code + shell: sh run: | + set -eu python - <<'PY' - import os - import tarfile - import urllib.request - - api_url = os.environ['GITHUB_API_URL'] - repository = os.environ['GITHUB_REPOSITORY'] - sha = os.environ['GITHUB_SHA'] - token = os.environ.get('GITHUB_TOKEN', '') - archive_url = f"{api_url}/repos/{repository}/archive/{sha}.tar.gz" - request = urllib.request.Request(archive_url) - if token: - request.add_header('Authorization', f'token {token}') + import io, os, tarfile, urllib.request + url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" + request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) with urllib.request.urlopen(request, timeout=120) as response: - with open('/tmp/repo.tar.gz', 'wb') as archive: - archive.write(response.read()) - with tarfile.open('/tmp/repo.tar.gz', 'r:gz') as archive: - members = archive.getmembers() - top_level = members[0].name.split('/')[0] + '/' - for member in members: - member.name = member.name.removeprefix(top_level) + archive = response.read() + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: + root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' + for member in tar.getmembers(): + name = member.name + if name == root_prefix[:-1]: + continue + if name.startswith(root_prefix): + member.name = name[len(root_prefix):] if member.name: - archive.extract(member, '.') + tar.extract(member, '.') PY - name: Verify CI environment + shell: sh run: | + set -eu python --version python -m pip --version python -m black --version @@ -57,27 +54,35 @@ jobs: python -m flake8 --version bandit --version pytest --version - echo "✅ Prebuilt CI environment is ready" + echo "CI environment is ready" - name: Run code quality checks + shell: sh run: | + set -eu python -m compileall -q alembic apps packages tests scripts python -m black --check alembic apps packages tests scripts python -m isort --check-only alembic apps packages tests scripts python -m flake8 apps packages tests --count --statistics - name: Run security scan + shell: sh run: | + set -eu bandit -r apps packages -q - name: Validate release scripts syntax + shell: sh run: | + set -eu bash -n scripts/backup_postgres.sh bash -n scripts/restore_postgres_plan.sh bash -n scripts/init_production_env.sh - name: Validate Alembic migrations + shell: sh run: | + set -eu DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas \ python -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql test -s /tmp/alembic-upgrade.sql @@ -85,12 +90,87 @@ jobs: python scripts/check_schema_metadata.py - name: Run tests + shell: sh run: | + set -eu PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/unit -q - name: Build summary if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main' + shell: sh run: | - echo "✅ Build completed successfully!" + set -eu + echo "Build completed successfully!" echo "Branch: ${GITHUB_REF_NAME}" echo "Commit: ${GITHUB_SHA}" + + frontend-lint: + name: Frontend Lint + runs-on: runtime-builder:host + + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + archive_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz" + wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url" + tar -xzf /tmp/repo.tar.gz --strip-components=1 -C . + rm -f /tmp/repo.tar.gz + + - name: Install dependencies + shell: sh + run: | + set -eu + docker run --rm \ + --pull=never \ + -v "$PWD:/workspace" \ + -w /workspace/apps/web \ + docker.m.daocloud.io/library/node:20 \ + sh -lc 'npm ci' + + - name: Run ESLint + shell: sh + run: | + set -eu + docker run --rm \ + --pull=never \ + -v "$PWD:/workspace" \ + -w /workspace/apps/web \ + docker.m.daocloud.io/library/node:20 \ + sh -lc 'npx eslint src --ext .ts,.tsx --max-warnings 0' + + - name: Run TypeScript type check + shell: sh + run: | + set -eu + docker run --rm \ + --pull=never \ + -v "$PWD:/workspace" \ + -w /workspace/apps/web \ + docker.m.daocloud.io/library/node:20 \ + sh -lc 'npx tsc --noEmit' + + - name: Run Prettier check + shell: sh + run: | + set -eu + docker run --rm \ + --pull=never \ + -v "$PWD:/workspace" \ + -w /workspace/apps/web \ + docker.m.daocloud.io/library/node:20 \ + sh -lc 'npx prettier --check "src/**/*.{ts,tsx,css,md}"' + + - name: Run Vitest tests + shell: sh + run: | + set -eu + docker run --rm \ + --pull=never \ + -v "$PWD:/workspace" \ + -w /workspace/apps/web \ + docker.m.daocloud.io/library/node:20 \ + sh -lc 'npx vitest run' diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index eda5e69b2..7ed922cd3 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,16 +1,16 @@ -name: Deploy +name: Deploy on: push: - branches: [ main, develop ] + branches: [ main, develop, "feature/**" ] tags: - 'v*' jobs: deploy-staging: name: Deploy Staging - runs-on: runtime-builder - if: github.ref_name == 'main' || github.ref_name == 'develop' + runs-on: runtime-builder:host + if: github.ref_name == 'main' || github.ref_name == 'develop' || startsWith(github.ref_name, 'feature/') steps: - name: Checkout code @@ -115,11 +115,11 @@ jobs: sleep 2 done exit 1 - REMOTE_DEPLOY +REMOTE_DEPLOY build-production-runtime-images: name: Build Production Runtime Images - runs-on: runtime-builder + runs-on: runtime-builder:host if: startsWith(github.ref, 'refs/tags/v') steps: @@ -195,7 +195,7 @@ jobs: deploy-production: name: Deploy Production - runs-on: runtime-builder + runs-on: runtime-builder:host if: startsWith(github.ref, 'refs/tags/v') needs: build-production-runtime-images @@ -264,11 +264,11 @@ jobs: sleep 2 done exit 1 - REMOTE_DEPLOY +REMOTE_DEPLOY production-e2e: name: Production Browser E2E - runs-on: runtime-builder + runs-on: runtime-builder:host if: startsWith(github.ref, 'refs/tags/v') needs: deploy-production diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml deleted file mode 100644 index cd4f5707f..000000000 --- a/.github/workflows/ci-cd.yml +++ /dev/null @@ -1,96 +0,0 @@ -name: CI/CD Pipeline - -on: - push: - branches: - - main - - develop - - 'feature/**' - - 'bugfix/**' - - 'hotfix/**' - - 'release/**' - pull_request: - branches: - - main - - develop - -jobs: - validate: - name: Validate Code Quality And Tests - runs-on: ubuntu-latest - container: xiaoxia-ci-python:3.12 - - steps: - - name: Checkout code - run: | - python - <<'PY' - import os - import tarfile - import urllib.request - - api_url = os.environ['GITHUB_API_URL'] - repository = os.environ['GITHUB_REPOSITORY'] - sha = os.environ['GITHUB_SHA'] - token = os.environ.get('GITHUB_TOKEN', '') - archive_url = f"{api_url}/repos/{repository}/archive/{sha}.tar.gz" - request = urllib.request.Request(archive_url) - if token: - request.add_header('Authorization', f'token {token}') - with urllib.request.urlopen(request, timeout=120) as response: - with open('/tmp/repo.tar.gz', 'wb') as archive: - archive.write(response.read()) - with tarfile.open('/tmp/repo.tar.gz', 'r:gz') as archive: - members = archive.getmembers() - top_level = members[0].name.split('/')[0] + '/' - for member in members: - member.name = member.name.removeprefix(top_level) - if member.name: - archive.extract(member, '.') - PY - - - name: Verify CI environment - run: | - python --version - python -m pip --version - python -m black --version - python -m isort --version-number - python -m flake8 --version - bandit --version - pytest --version - echo "✅ Prebuilt CI environment is ready" - - - name: Run code quality checks - run: | - python -m compileall -q alembic apps packages tests scripts - python -m black --check alembic apps packages tests scripts - python -m isort --check-only alembic apps packages tests scripts - python -m flake8 apps packages tests --count --statistics - - - name: Run security scan - run: | - bandit -r apps packages -q - - - name: Validate release scripts syntax - run: | - bash -n scripts/backup_postgres.sh - bash -n scripts/restore_postgres_plan.sh - bash -n scripts/init_production_env.sh - - - name: Validate Alembic migrations - run: | - DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas \ - python -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql - test -s /tmp/alembic-upgrade.sql - grep -q "Running upgrade" /tmp/alembic-upgrade.sql - python scripts/check_schema_metadata.py - - - name: Run tests - run: | - python -m pytest tests -q - - - name: Build summary - if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main' - run: | - echo "✅ Build completed successfully!" - echo "Branch: ${GITHUB_REF_NAME}" - echo "Commit: ${GITHUB_SHA}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100755 index 06e6931e2..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,414 +0,0 @@ -# =========================================== -# 小虾剪辑 SaaS 发布工作流 -# =========================================== -# -# 触发条件: -# - 推送 v* 格式的 tag (如 v0.1.5) -# -# 完整流程: -# 1. 构建 API + Worker 镜像 -# 2. 构建 Web 镜像 -# 3. 创建 Release -# 4. 上传到生产服务器 -# 5. 执行部署 -# 6. 运行 E2E 测试 -# -# 环境变量: -# PRODUCTION_SSH_HOST - 生产服务器地址 -# PRODUCTION_SSH_USER - 生产服务器用户名 -# PRODUCTION_SSH_KEY - SSH 私钥 -# - -name: Release Pipeline - -on: - push: - tags: - - 'v*' - -env: - VERSION: ${ { github.ref_name } } - -jobs: - # ========================================= - # Job 1: 构建所有镜像 - # ========================================= - build-images: - name: Build All Images - runs-on: runtime-builder:host - container: localhost:5000/xiaoxia-ci-python:3.12 - - outputs: - version: ${{ env.VERSION }} - - steps: - # 检出代码 - - name: Checkout code - run: | - python - <<'PY' - import os - import tarfile - import urllib.request - - api_url = os.environ['GITHUB_API_URL'] - repository = os.environ['GITHUB_REPOSITORY'] - sha = os.environ['GITHUB_SHA'] - token = os.environ.get('GITHUB_TOKEN', '') - archive_url = f"{api_url}/repos/{repository}/archive/{sha}.tar.gz" - request = urllib.request.Request(archive_url) - if token: - request.add_header('Authorization', f'token {token}') - with urllib.request.urlopen(request, timeout=120) as response: - with open('/tmp/repo.tar.gz', 'wb') as archive: - archive.write(response.read()) - with tarfile.open('/tmp/repo.tar.gz', 'r:gz') as archive: - members = archive.getmembers() - top_level = members[0].name.split('/')[0] + '/' - for member in members: - member.name = member.name.removeprefix(top_level) - if member.name: - archive.extract(member, '.') - PY - - # 构建 API 镜像 - - name: Build API Image - run: | - VERSION="${{ env.VERSION }}" - API_IMAGE="xiaoxia-saas-api:$VERSION" - docker build --pull=false \ - -f infra/docker/api.Dockerfile \ - -t "$API_IMAGE" \ - -t "xiaoxia-saas-api:latest" \ - . - - # 构建 Worker 镜像 - - name: Build Worker Image - run: | - VERSION="${{ env.VERSION }}" - WORKER_IMAGE="xiaoxia-saas-worker:$VERSION" - docker build --pull=false \ - -f infra/docker/worker.Dockerfile \ - -t "$WORKER_IMAGE" \ - -t "xiaoxia-saas-worker:latest" \ - . - - # 构建 Web 镜像 - - name: Build Web Image - run: | - VERSION="${{ env.VERSION }}" - # 先构建前端 - docker run --rm \ - --pull=never \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - docker.m.daocloud.io/library/node:20 \ - sh -lc 'npm ci && npm run build' - # 构建 Nginx 镜像 - docker build --pull=false \ - -f infra/docker/web-artifact.Dockerfile \ - -t "xiaoxia-saas-web:$VERSION" \ - . - - # 保存镜像 - - name: Save Images - run: | - VERSION="${{ env.VERSION }}" - mkdir -p dist/release-images dist/release-artifacts - - # 保存 API + Worker 镜像 - docker save \ - "xiaoxia-saas-api:$VERSION" \ - "xiaoxia-saas-api:latest" \ - "xiaoxia-saas-worker:$VERSION" \ - "xiaoxia-saas-worker:latest" \ - -o dist/release-images/xiaoxia-runtime-images-$VERSION.tar - - # 保存 Web 镜像 - docker save "xiaoxia-saas-web:$VERSION" \ - -o dist/release-images/xiaoxia-web-$VERSION.tar - - # 上传镜像到构建服务器存储 - - name: Upload Images Artifact - uses: actions/upload-artifact@v4 - with: - name: release-images-${{ env.VERSION }} - path: dist/release-images/ - retention-days: 7 - - # ========================================= - # Job 2: 打包源码 - # ========================================= - package-source: - name: Package Source - runs-on: runtime-builder:host - - needs: build-images - - steps: - - name: Checkout code - run: | - python - <<'PY' - import os - import tarfile - import urllib.request - - api_url = os.environ['GITHUB_API_URL'] - repository = os.environ['GITHUB_REPOSITORY'] - sha = os.environ['GITHUB_SHA'] - token = os.environ.get('GITHUB_TOKEN', '') - archive_url = f"{api_url}/repos/{repository}/archive/{sha}.tar.gz" - request = urllib.request.Request(archive_url) - if token: - request.add_header('Authorization', f'token {token}') - with urllib.request.urlopen(request, timeout=120) as response: - with open('/tmp/repo.tar.gz', 'wb') as archive: - archive.write(response.read()) - with tarfile.open('/tmp/repo.tar.gz', 'r:gz') as archive: - members = archive.getmembers() - top_level = members[0].name.split('/')[0] + '/' - for member in members: - member.name = member.name.removeprefix(top_level) - if member.name: - archive.extract(member, '.') - PY - - - name: Package Source - run: | - VERSION="${{ env.VERSION }}" - mkdir -p dist/release-artifacts - - # 排除不需要的文件 - tar --exclude=.git \ - --exclude=apps/web/node_modules \ - --exclude=apps/web/dist \ - --exclude=dist \ - --exclude=*.pyc \ - --exclude=__pycache__ \ - -czf "dist/release-artifacts/xiaoxia-release-$VERSION.tar.gz" . - - - name: Upload Source Artifact - uses: actions/upload-artifact@v4 - with: - name: release-source-${{ env.VERSION }} - path: dist/release-artifacts/ - retention-days: 7 - - # ========================================= - # Job 3: 部署到生产环境 - # ========================================= - deploy-production: - name: Deploy to Production - runs-on: runtime-builder:host - needs: [build-images, package-source] - - steps: - - name: Download Images - uses: actions/download-artifact@v4 - with: - name: release-images-${{ env.VERSION }} - path: dist/release-images/ - - - name: Download Source - uses: actions/download-artifact@v4 - with: - name: release-source-${{ env.VERSION }} - path: dist/release-artifacts/ - - - name: Upload to Production Server - env: - PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }} - PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }} - PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} - run: | - VERSION="${{ env.VERSION }}" - production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}" - production_user="${PRODUCTION_SSH_USER:-root}" - - mkdir -p ~/.ssh - if [ -n "${PRODUCTION_SSH_KEY:-}" ]; then - key_path="$HOME/.ssh/id_ed25519" - printf '%s\n' "$PRODUCTION_SSH_KEY" > "$key_path" - chmod 600 "$key_path" - else - key_path="/root/.ssh/xiaoxia_runtime_builder" - fi - - ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts 2>/dev/null || true - - # 创建目录 - ssh -i "$key_path" "$production_user@$production_host" \ - "mkdir -p /var/lib/xiaoxia-saas-production" - - - name: Transfer and Deploy - env: - PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }} - PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }} - PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} - run: | - VERSION="${{ env.VERSION }}" - production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}" - production_user="${PRODUCTION_SSH_USER:-root}" - - if [ -n "${PRODUCTION_SSH_KEY:-}" ]; then - key_path="$HOME/.ssh/id_ed25519" - else - key_path="/root/.ssh/xiaoxia_runtime_builder" - fi - - # 上传镜像 - scp -i "$key_path" \ - "dist/release-images/xiaoxia-runtime-images-$VERSION.tar" \ - "$production_user@$production_host:/var/lib/xiaoxia-saas-production/" - - scp -i "$key_path" \ - "dist/release-images/xiaoxia-web-$VERSION.tar" \ - "$production_user@$production_host:/var/lib/xiaoxia-saas-production/" - - # 上传源码 - scp -i "$key_path" \ - "dist/release-artifacts/xiaoxia-release-$VERSION.tar.gz" \ - "$production_user@$production_host:/var/lib/xiaoxia-saas-production/" - - - name: Execute Deployment - env: - PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }} - PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }} - PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} - run: | - VERSION="${{ env.VERSION }}" - production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}" - production_user="${PRODUCTION_SSH_USER:-root}" - - if [ -n "${PRODUCTION_SSH_KEY:-}" ]; then - key_path="$HOME/.ssh/id_ed25519" - else - key_path="/root/.ssh/xiaoxia_runtime_builder" - fi - - ssh -i "$key_path" "$production_user@$production_host" <<'REMOTE_DEPLOY' - set -eu - - VERSION="${{ env.VERSION }}" - cd /var/lib/xiaoxia-saas-production - - # 加载镜像 - docker load -i "runtime-images-$VERSION.tar" - docker load -i "web-$VERSION.tar" - - # 解压源码 - rm -rf repo - mkdir -p repo - tar -xzf "release-$VERSION.tar.gz" -C repo - - # 复制环境文件 - cp .env repo/.env - - # 部署 - cd repo - export API_IMAGE="xiaoxia-saas-api:$VERSION" - export WORKER_IMAGE="xiaoxia-saas-worker:$VERSION" - export WEB_IMAGE="xiaoxia-saas-web:$VERSION" - export APP_VERSION="$VERSION" - export RELEASE_VERSION="$VERSION" - - bash infra/docker/deploy-production.sh - - # 健康检查 - i=0 - while [ "$i" -lt 30 ]; do - if wget -qO- http://127.0.0.1:8001/health; then - echo "Deployment successful!" - exit 0 - fi - i=$((i + 1)) - sleep 2 - done - - echo "Health check failed!" - exit 1 - REMOTE_DEPLOY - - # ========================================= - # Job 4: 运行 E2E 测试 - # ========================================= - e2e-test: - name: Run E2E Tests - runs-on: runtime-builder:host - needs: deploy-production - - steps: - - name: Checkout code - run: | - python - <<'PY' - import os - import tarfile - import urllib.request - - api_url = os.environ['GITHUB_API_URL'] - repository = os.environ['GITHUB_REPOSITORY'] - sha = os.environ['GITHUB_SHA'] - token = os.environ.get('GITHUB_TOKEN', '') - archive_url = f"{api_url}/repos/{repository}/archive/{sha}.tar.gz" - request = urllib.request.Request(archive_url) - if token: - request.add_header('Authorization', f'token {token}') - with urllib.request.urlopen(request, timeout=120) as response: - with open('/tmp/repo.tar.gz', 'wb') as archive: - archive.write(response.read()) - with tarfile.open('/tmp/repo.tar.gz', 'r:gz') as archive: - members = archive.getmembers() - top_level = members[0].name.split('/')[0] + '/' - for member in members: - member.name = member.name.removeprefix(top_level) - if member.name: - archive.extract(member, '.') - PY - - - name: Run Playwright E2E - run: | - docker run --rm \ - -e E2E_BASE_URL=https://saas.xiaoxiajianji.com \ - -e E2E_API_BASE=https://api.xiaoxiajianji.com/api/v1 \ - -e E2E_BROWSER_CHANNEL=chromium \ - -v "$PWD:/workspace" \ - -w /workspace/apps/web \ - mcr.microsoft.com/playwright:v1.45.0-jammy \ - sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/' - - # ========================================= - # Job 5: 创建 GitHub Release - # ========================================= - create-release: - name: Create Release - runs-on: ubuntu-latest - needs: [deploy-production, e2e-test] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Generate Changelog - id: changelog - run: | - VERSION=${GITHUB_REF#refs/tags/} - # 简单获取 CHANGELOG 中该版本的内容 - CHANGELOG=$(awk "/^## \[$VERSION\]/,/^## \[/" CHANGELOG.md | head -n -1) - echo "body<> $GITHUB_OUTPUT - echo "## What's Changed" >> $GITHUB_OUTPUT - echo "" >> $GITHUB_OUTPUT - echo "$CHANGELOG" >> $GITHUB_OUTPUT - echo "" >> $GITHUB_OUTPUT - echo "**Full Changelog**: https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Create Release - uses: softprops/action-gh-release@v1 - with: - body: ${{ steps.changelog.outputs.body }} - draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml deleted file mode 100644 index e9b94eb3b..000000000 --- a/.github/workflows/security.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Security Scan - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main ] - schedule: - # Run every Monday at 00:00 UTC - - cron: '0 0 * * 1' - -jobs: - security-scan: - name: Security Scan - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.12' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install safety bandit - - - name: Check for known security vulnerabilities - run: | - pip install -r requirements.txt - safety check --json - - - name: Run Bandit security linter - run: | - bandit -r packages/ apps/ -f json -o bandit-report.json || true - cat bandit-report.json - - - name: Upload security reports - uses: actions/upload-artifact@v3 - if: always() - with: - name: security-reports - path: | - bandit-report.json - - dependency-review: - name: Dependency Review - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Dependency Review - uses: actions/dependency-review-action@v3 diff --git a/alembic/versions/007_remove_workspace_concept.py b/alembic/versions/007_remove_workspace_concept.py new file mode 100644 index 000000000..7a8a7845e --- /dev/null +++ b/alembic/versions/007_remove_workspace_concept.py @@ -0,0 +1,203 @@ +"""Remove workspace concept - Projects now directly under User + +Revision ID: 007_remove_workspace_concept +Revises: 006_add_edit_plan_tables +Create Date: 2026-06-26 + +This migration: +1. Moves subscription/quota fields from workspaces to users table +2. Converts projects.workspace_id to projects.owner_user_id +3. Adds shared_users JSON field to projects table +4. Removes workspace_id from all tables that had it +5. Drops workspace-related tables: workspaces, workspace_members, workspace_invitations +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text + +# revision identifiers +revision = "007_remove_workspace_concept" +down_revision = "006_add_edit_plan_tables" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + + # Step 1: Add subscription/quota fields to users table + conn.execute(text(""" + ALTER TABLE users + ADD COLUMN IF NOT EXISTS subscription_plan VARCHAR(20) NOT NULL DEFAULT free + """)) + conn.execute(text(""" + ALTER TABLE users + ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(20) NOT NULL DEFAULT active + """)) + conn.execute(text(""" + ALTER TABLE users + ADD COLUMN IF NOT EXISTS subscription_expires_at TIMESTAMP + """)) + conn.execute(text(""" + ALTER TABLE users + ADD COLUMN IF NOT EXISTS max_projects FLOAT NOT NULL DEFAULT 3 + """)) + conn.execute(text(""" + ALTER TABLE users + ADD COLUMN IF NOT EXISTS max_storage_gb FLOAT NOT NULL DEFAULT 10 + """)) + conn.execute(text(""" + ALTER TABLE users + ADD COLUMN IF NOT EXISTS used_storage_gb FLOAT NOT NULL DEFAULT 0 + """)) + + # Step 2: Copy subscription data from workspaces to users + conn.execute(text(""" + UPDATE users SET + subscription_plan = w.subscription_plan, + subscription_status = w.subscription_status, + subscription_expires_at = w.subscription_expires_at, + max_projects = w.max_projects, + max_storage_gb = w.max_storage_gb, + used_storage_gb = w.used_storage_gb + FROM workspaces w + WHERE w.owner_user_id = users.id + """)) + + # Step 3: Add owner_user_id and shared_users to projects table + conn.execute(text(""" + ALTER TABLE projects + ADD COLUMN IF NOT EXISTS owner_user_id VARCHAR(32) + """)) + conn.execute(text(""" + ALTER TABLE projects + ADD COLUMN IF NOT EXISTS shared_users JSON + """)) + + # Step 4: Migrate workspace_id to owner_user_id (from workspace_members where role=owner) + conn.execute(text(""" + UPDATE projects SET + owner_user_id = wm.user_id + FROM workspace_members wm + WHERE wm.workspace_id = projects.workspace_id + AND wm.role = owner + """)) + + # Set shared_users to empty array for all projects + conn.execute(text(""" + UPDATE projects SET shared_users = []::json + WHERE shared_users IS NULL + """)) + + # Step 5: Remove workspace_id from all tables + tables_with_workspace_id = [ + "asset_libraries", + "assets", + "classification_jobs", + "edit_plans", + "edit_templates", + "generation_tasks", + "generated_videos", + "ingest_jobs", + "milestones", + "project_titles", + "tasks", + "task_issues", + ] + + for table in tables_with_workspace_id: + conn.execute(text(f""" + ALTER TABLE {table} DROP COLUMN IF EXISTS workspace_id + """)) + + # Step 6: Drop workspace-related tables + conn.execute(text(""" + DROP TABLE IF EXISTS workspace_invitations + """)) + conn.execute(text(""" + DROP TABLE IF EXISTS workspace_members + """)) + conn.execute(text(""" + DROP TABLE IF EXISTS workspaces + """)) + + # Step 7: Drop workspace_id from projects table + conn.execute(text(""" + ALTER TABLE projects DROP COLUMN IF EXISTS workspace_id + """)) + + +def downgrade() -> None: + conn = op.get_bind() + + # Add back workspace tables (simplified - in real scenario would need full recreation) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS workspaces ( + id VARCHAR(36) PRIMARY KEY, + name VARCHAR(100) NOT NULL, + owner_user_id VARCHAR(36) NOT NULL, + subscription_plan VARCHAR(20) NOT NULL DEFAULT free, + subscription_status VARCHAR(20) NOT NULL DEFAULT active, + subscription_expires_at TIMESTAMP, + max_projects FLOAT NOT NULL DEFAULT 3, + max_storage_gb FLOAT NOT NULL DEFAULT 10, + used_storage_gb FLOAT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """)) + + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS workspace_members ( + id VARCHAR(36) PRIMARY KEY, + workspace_id VARCHAR(36) NOT NULL, + user_id VARCHAR(36) NOT NULL, + role VARCHAR(20) NOT NULL, + invited_by VARCHAR(36), + joined_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(workspace_id, user_id) + ) + """)) + + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS workspace_invitations ( + id VARCHAR(36) PRIMARY KEY, + workspace_id VARCHAR(36) NOT NULL, + inviter_user_id VARCHAR(36) NOT NULL, + invitee_email VARCHAR(255) NOT NULL, + role VARCHAR(20) NOT NULL, + invitation_token VARCHAR(255) NOT NULL UNIQUE, + status VARCHAR(20) NOT NULL DEFAULT pending, + expires_at TIMESTAMP, + accepted_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """)) + + # Add back workspace_id column to projects + conn.execute(text(""" + ALTER TABLE projects ADD COLUMN workspace_id VARCHAR(32) + """)) + + # Add back workspace_id columns to other tables + tables_with_workspace_id = [ + "asset_libraries", + "assets", + "classification_jobs", + "edit_plans", + "edit_templates", + "generation_tasks", + "generated_videos", + "ingest_jobs", + "milestones", + "project_titles", + "tasks", + "task_issues", + ] + + for table in tables_with_workspace_id: + conn.execute(text(f""" + ALTER TABLE {table} ADD COLUMN workspace_id VARCHAR(36) + """)) + + # Note: This downgrade is incomplete - projects.owner_user_id data would need to be + # converted back to workspace_ids, which requires reconstructing workspace records. diff --git a/apps/api/app/api/routes/asset_libraries.py b/apps/api/app/api/routes/asset_libraries.py index c3465c09d..1bd0acaa5 100644 --- a/apps/api/app/api/routes/asset_libraries.py +++ b/apps/api/app/api/routes/asset_libraries.py @@ -1,7 +1,7 @@ from typing import Any from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import get_asset_library_repository, get_project_repository, get_workspace_member_repository +from app.dependencies import get_asset_library_repository, get_project_repository from app.schemas.asset_library import ( AssetLibraryResponse, CreateAssetLibraryRequest, @@ -16,25 +16,22 @@ from packages.application import ( ListAssetLibrariesUseCase, ) from packages.domain import AssetLibraryKind -from packages.ports.workspace_member_repository import WorkspaceMemberRepository router = APIRouter() -def _require_workspace_member( - workspace_id: str, - authenticated_user: AuthenticatedUser, - workspace_member_repository: WorkspaceMemberRepository, -) -> None: - member = workspace_member_repository.find_by_workspace_and_user(workspace_id, authenticated_user.user.id) - if member is None: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Workspace access denied") +def _check_project_access(project_id: str, user_id: str, project_repository) -> None: + """检查用户是否有项目访问权限""" + project = project_repository.find_by_id(project_id) + if project is None: + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + if not project.can_access(user_id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project") def _to_asset_library_response(item) -> AssetLibraryResponse: return AssetLibraryResponse( id=item.id, - workspace_id=item.workspace_id, project_id=item.project_id, name=item.name, kind=item.kind.value, @@ -46,19 +43,17 @@ def _to_asset_library_response(item) -> AssetLibraryResponse: @router.get("", response_model=ListAssetLibrariesResponse) def list_asset_libraries( project_id: str, - kind: str | None = None, authenticated_user: AuthenticatedUser = Depends(get_current_user), asset_library_repository: Any = Depends(get_asset_library_repository), project_repository: Any = Depends(get_project_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), ) -> ListAssetLibrariesResponse: project = GetProjectUseCase(project_repository).execute(project_id) if project is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") - _require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository) + if not project.can_access(authenticated_user.id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project") use_case = ListAssetLibrariesUseCase(asset_library_repository) - parsed_kind = AssetLibraryKind(kind) if kind else None - items = use_case.execute(project_id, kind=parsed_kind) + items = use_case.execute(project_id) return ListAssetLibrariesResponse(items=[_to_asset_library_response(item) for item in items]) @@ -68,16 +63,15 @@ def create_asset_library( authenticated_user: AuthenticatedUser = Depends(get_current_user), asset_library_repository: Any = Depends(get_asset_library_repository), project_repository: Any = Depends(get_project_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), ) -> AssetLibraryResponse: - project = GetProjectUseCase(project_repository).execute(request.project_id) - if project is None or project.workspace_id != request.workspace_id: + project = project_repository.find_by_id(request.project_id) + if project is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") - _require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository) + if not project.can_access(authenticated_user.id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project") use_case = CreateAssetLibraryUseCase(asset_library_repository) item = use_case.execute( CreateAssetLibraryCommand( - workspace_id=request.workspace_id, project_id=request.project_id, name=request.name, kind=AssetLibraryKind(request.kind), diff --git a/apps/api/app/api/routes/assets.py b/apps/api/app/api/routes/assets.py index 76d5c050a..7fa7ef839 100644 --- a/apps/api/app/api/routes/assets.py +++ b/apps/api/app/api/routes/assets.py @@ -1,12 +1,10 @@ from typing import Any -from app.api.routes.permissions import require_workspace_member from app.auth import AuthenticatedUser, get_current_user from app.dependencies import ( get_asset_library_repository, get_asset_repository, get_project_repository, - get_workspace_member_repository, ) from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse, UpdateAssetReviewRequest from fastapi import APIRouter, Depends, HTTPException @@ -17,7 +15,6 @@ from packages.application import ( ListAssetsUseCase, ) from packages.domain import AssetStatus, ClassificationStatus -from packages.ports.workspace_member_repository import WorkspaceMemberRepository router = APIRouter() @@ -25,7 +22,6 @@ router = APIRouter() def _to_asset_response(item) -> AssetResponse: return AssetResponse( id=item.id, - workspace_id=item.workspace_id, project_id=item.project_id, library_id=item.library_id, name=item.name, @@ -46,18 +42,27 @@ def _to_asset_response(item) -> AssetResponse: ) +def _check_project_access(project_id: str, user_id: str, project_repository) -> None: + """检查用户是否有项目访问权限""" + project = project_repository.find_by_id(project_id) + if project is None: + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + if not project.can_access(user_id): + raise HTTPException(status_code=403, detail="Access denied to project") + + @router.get("", response_model=ListAssetsResponse) def list_assets( library_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), asset_repository: Any = Depends(get_asset_repository), asset_library_repository: Any = Depends(get_asset_library_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), + project_repository: Any = Depends(get_project_repository), ) -> ListAssetsResponse: library = asset_library_repository.get(library_id) if library is None: raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found") - require_workspace_member(library.workspace_id, authenticated_user, workspace_member_repository) + _check_project_access(library.project_id, authenticated_user.id, project_repository) use_case = ListAssetsUseCase(asset_repository) items = use_case.execute(library_id) return ListAssetsResponse(items=[_to_asset_response(item) for item in items]) @@ -77,12 +82,12 @@ def update_asset_review_status( request: UpdateAssetReviewRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), asset_repository: Any = Depends(get_asset_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), + project_repository: Any = Depends(get_project_repository), ) -> AssetResponse: item = asset_repository.get(asset_id) if item is None: raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found") - require_workspace_member(item.workspace_id, authenticated_user, workspace_member_repository) + _check_project_access(item.project_id, authenticated_user.id, project_repository) _apply_asset_review_status(item, request.review_status) updated = asset_repository.update(item) return _to_asset_response(updated) @@ -95,20 +100,20 @@ def create_asset( asset_repository: Any = Depends(get_asset_repository), asset_library_repository: Any = Depends(get_asset_library_repository), project_repository: Any = Depends(get_project_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), ) -> AssetResponse: - require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository) project = project_repository.find_by_id(request.project_id) - if project is None or project.workspace_id != request.workspace_id: + if project is None: raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found") + if not project.can_access(authenticated_user.id): + raise HTTPException(status_code=403, detail="Access denied to project") + library = asset_library_repository.get(request.library_id) - if library is None or library.workspace_id != request.workspace_id or library.project_id != request.project_id: + if library is None or library.project_id != request.project_id: raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found") use_case = CreateAssetUseCase(asset_repository) item = use_case.execute( CreateAssetCommand( - workspace_id=request.workspace_id, project_id=request.project_id, library_id=request.library_id, name=request.name, @@ -125,7 +130,7 @@ def create_asset( status=AssetStatus(request.status), classification_status=ClassificationStatus(request.classification_status), quality_score=request.quality_score, - uploaded_by_user_id=authenticated_user.user.id, + uploaded_by_user_id=authenticated_user.id, ) ) return _to_asset_response(item) diff --git a/apps/api/app/api/routes/classification_jobs.py b/apps/api/app/api/routes/classification_jobs.py index e5161bd58..0c713b1f1 100644 --- a/apps/api/app/api/routes/classification_jobs.py +++ b/apps/api/app/api/routes/classification_jobs.py @@ -27,10 +27,9 @@ def get_classification_job( raise HTTPException(status_code=404, detail=f"ClassificationJob {job_id} not found") return ClassificationJobResponse( id=job.id, - workspace_id=job.workspace_id, project_id=job.project_id, asset_id=job.asset_id, - status=job.status.value, + status=job.status, classification=job.classification, confidence=job.confidence, error_message=job.error_message, @@ -45,7 +44,6 @@ def submit_classification_job( use_case = SubmitClassificationJobUseCase(classification_job_repository) job = use_case.execute( SubmitClassificationJobCommand( - workspace_id=request.workspace_id, project_id=request.project_id, asset_id=request.asset_id, ) @@ -53,10 +51,9 @@ def submit_classification_job( celery_app.send_task("worker.classify_asset", args=[job.id]) return ClassificationJobResponse( id=job.id, - workspace_id=job.workspace_id, project_id=job.project_id, asset_id=job.asset_id, - status=job.status.value, + status=job.status, classification=job.classification, confidence=job.confidence, error_message=job.error_message, diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index 14e9b5e41..ceb3f204c 100644 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -1,6 +1,5 @@ from typing import Any -from app.api.routes.permissions import require_workspace_member from app.auth import AuthenticatedUser, get_current_user from app.core.celery_app import celery_app from app.dependencies import ( @@ -10,7 +9,6 @@ from app.dependencies import ( get_generation_task_repository, get_project_repository, get_project_title_repository, - get_workspace_member_repository, ) from app.schemas.generated_video import ( GeneratedVideoResponse, @@ -28,21 +26,28 @@ from packages.application import ( GetGenerationTaskUseCase, ListGeneratedVideosByTaskUseCase, ) -from packages.ports.workspace_member_repository import WorkspaceMemberRepository router = APIRouter() +def _check_project_access(project_id: str, user_id: str, project_repository) -> None: + """检查用户是否有项目访问权限""" + project = project_repository.find_by_id(project_id) + if project is None: + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + if not project.can_access(user_id): + raise HTTPException(status_code=403, detail="Access denied to project") + + def _to_generation_task_response(task) -> GenerationTaskResponse: return GenerationTaskResponse( id=task.id, - workspace_id=task.workspace_id, project_id=task.project_id, asset_library_id=task.asset_library_id, strategy_id=task.strategy_id, voice_library_id=task.voice_library_id, edit_plan_id=task.edit_plan_id, - status=task.status.value, + status=task.status, progress=task.progress, result_count=task.result_count, error_message=task.error_message, @@ -52,7 +57,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse: def _to_generated_video_response(item) -> GeneratedVideoResponse: return GeneratedVideoResponse( id=item.id, - workspace_id=item.workspace_id, project_id=item.project_id, generation_task_id=item.generation_task_id, name=item.name, @@ -92,7 +96,7 @@ def _select_title_id(project_title_repository: Any, project_id: str) -> str: return selected.id -@router.post("/tasks", response_model=GenerationTaskResponse) +@router.post("/tasks/", response_model=GenerationTaskResponse) def create_generation_task( request: CreateGenerationTaskRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -101,28 +105,29 @@ def create_generation_task( asset_library_repository: Any = Depends(get_asset_library_repository), asset_repository: Any = Depends(get_asset_repository), project_title_repository: Any = Depends(get_project_title_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), ) -> GenerationTaskResponse: - require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository) project = project_repository.find_by_id(request.project_id) - if project is None or project.workspace_id != request.workspace_id: + if project is None: raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found") + if not project.can_access(authenticated_user.id): + raise HTTPException(status_code=403, detail="Access denied to project") + library = asset_library_repository.get(request.asset_library_id) - if library is None or library.workspace_id != request.workspace_id or library.project_id != request.project_id: + if library is None or library.project_id != request.project_id: raise HTTPException(status_code=404, detail=f"AssetLibrary {request.asset_library_id} not found") + assets = asset_repository.list_by_library(request.asset_library_id) _ensure_library_has_ready_video_assets(assets) use_case = CreateGenerationTaskUseCase(generation_task_repository) task = use_case.execute( CreateGenerationTaskCommand( - workspace_id=request.workspace_id, project_id=request.project_id, asset_library_id=request.asset_library_id, strategy_id=request.strategy_id or _select_title_id(project_title_repository, request.project_id), voice_library_id=request.voice_library_id, edit_plan_id=request.edit_plan_id, - created_by_user_id=authenticated_user.user.id, + created_by_user_id=authenticated_user.id, ) ) celery_app.send_task("worker.generate_video", args=[task.id]) @@ -134,28 +139,28 @@ def get_generation_task( task_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), generation_task_repository: Any = Depends(get_generation_task_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), + project_repository: Any = Depends(get_project_repository), ) -> GenerationTaskResponse: use_case = GetGenerationTaskUseCase(generation_task_repository) task = use_case.execute(task_id) if task is None: raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found") - require_workspace_member(task.workspace_id, authenticated_user, workspace_member_repository) + _check_project_access(task.project_id, authenticated_user.id, project_repository) return _to_generation_task_response(task) -@router.get("/tasks/{task_id}/results", response_model=ListGeneratedVideosResponse) +@router.get("/tasks/{task_id}/results/", response_model=ListGeneratedVideosResponse) def list_generation_results( task_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), generation_task_repository: Any = Depends(get_generation_task_repository), generated_video_repository: Any = Depends(get_generated_video_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), + project_repository: Any = Depends(get_project_repository), ) -> ListGeneratedVideosResponse: task = generation_task_repository.get(task_id) if task is None: raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found") - require_workspace_member(task.workspace_id, authenticated_user, workspace_member_repository) + _check_project_access(task.project_id, authenticated_user.id, project_repository) use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository) items = use_case.execute(task_id) return ListGeneratedVideosResponse(items=[_to_generated_video_response(item) for item in items]) diff --git a/apps/api/app/api/routes/ingest_jobs.py b/apps/api/app/api/routes/ingest_jobs.py index 1c6421055..b7148ae93 100644 --- a/apps/api/app/api/routes/ingest_jobs.py +++ b/apps/api/app/api/routes/ingest_jobs.py @@ -20,7 +20,6 @@ def get_ingest_job( raise ValueError(f"IngestJob {job_id} not found") return IngestJobResponse( id=job.id, - workspace_id=job.workspace_id, project_id=job.project_id, library_id=job.library_id, storage_key=job.storage_key, @@ -38,7 +37,6 @@ def submit_ingest_job( use_case = SubmitIngestJobUseCase(ingest_job_repository) job = use_case.execute( SubmitIngestJobCommand( - workspace_id=request.workspace_id, project_id=request.project_id, library_id=request.library_id, storage_key=request.storage_key, @@ -49,7 +47,6 @@ def submit_ingest_job( return IngestJobResponse( id=job.id, - workspace_id=job.workspace_id, project_id=job.project_id, library_id=job.library_id, storage_key=job.storage_key, diff --git a/apps/api/app/api/routes/permissions.py b/apps/api/app/api/routes/permissions.py deleted file mode 100644 index 4a16d4d95..000000000 --- a/apps/api/app/api/routes/permissions.py +++ /dev/null @@ -1,16 +0,0 @@ -from __future__ import annotations - -from app.auth import AuthenticatedUser -from fastapi import HTTPException - -from packages.ports.workspace_member_repository import WorkspaceMemberRepository - - -def require_workspace_member( - workspace_id: str, - authenticated_user: AuthenticatedUser, - workspace_member_repository: WorkspaceMemberRepository, -) -> None: - member = workspace_member_repository.find_by_workspace_and_user(workspace_id, authenticated_user.user.id) - if member is None: - raise HTTPException(status_code=403, detail="Workspace access denied") diff --git a/apps/api/app/api/routes/workspaces.py b/apps/api/app/api/routes/workspaces.py deleted file mode 100644 index b8b917ef2..000000000 --- a/apps/api/app/api/routes/workspaces.py +++ /dev/null @@ -1,369 +0,0 @@ -""" -Workspace API routes wired through the canonical API composition root. - -Only the core create/list/detail endpoints are enabled here. Member invitation -and subscription endpoints remain in application use cases but are mounted only -after their persistence repositories are wired and smoke-tested. -""" - -from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import ( - get_user_repository, - get_workspace_invitation_repository, - get_workspace_member_repository, - get_workspace_repository, -) -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, EmailStr - -from packages.application.workspace.accept_invitation_use_case import AcceptInvitationRequest, AcceptInvitationUseCase -from packages.application.workspace.create_workspace_use_case import CreateWorkspaceRequest, CreateWorkspaceUseCase -from packages.application.workspace.invite_member_use_case import InviteMemberRequest, InviteMemberUseCase -from packages.application.workspace.list_members_use_case import ListMembersRequest, ListMembersUseCase -from packages.application.workspace.list_workspaces_use_case import ( - GetWorkspaceDetailRequest, - GetWorkspaceDetailUseCase, - ListWorkspacesRequest, - ListWorkspacesUseCase, -) -from packages.application.workspace.remove_member_use_case import RemoveMemberRequest, RemoveMemberUseCase -from packages.application.workspace.update_member_role_use_case import UpdateMemberRoleRequest, UpdateMemberRoleUseCase -from packages.ports.user_repository import UserRepository -from packages.ports.workspace_invitation_repository import WorkspaceInvitationRepository -from packages.ports.workspace_member_repository import WorkspaceMemberRepository -from packages.ports.workspace_repository import WorkspaceRepository - -router = APIRouter(prefix="/workspaces", tags=["Workspaces"]) - - -class CreateWorkspaceRequestModel(BaseModel): - name: str - subscription_plan: str = "free" - - -class WorkspaceResponseModel(BaseModel): - workspace_id: str - name: str - subscription_plan: str - max_projects: int - max_storage_gb: int - - -class WorkspaceSummaryModel(BaseModel): - workspace_id: str - name: str - subscription_plan: str - max_projects: int - max_storage_gb: int - member_count: int - user_role: str - - -class WorkspaceListResponseModel(BaseModel): - workspaces: list[WorkspaceSummaryModel] - - -class WorkspaceDetailResponseModel(BaseModel): - workspace_id: str - name: str - owner_user_id: str - subscription_plan: str - subscription_status: str - max_projects: int - max_storage_gb: int - used_storage_gb: float - member_count: int - user_role: str - - -class InviteMemberRequestModel(BaseModel): - email: EmailStr - role: str - - -class InviteMemberResponseModel(BaseModel): - invitation_id: str - invitee_email: str - role: str - expires_at: str - - -class MemberResponseModel(BaseModel): - member_id: str - user_id: str - username: str - email: str - display_name: str - role: str - invited_by: str | None - joined_at: str - - -class MemberListResponseModel(BaseModel): - members: list[MemberResponseModel] - - -class UpdateMemberRoleRequestModel(BaseModel): - role: str - - -class UpdateMemberRoleResponseModel(BaseModel): - user_id: str - old_role: str - new_role: str - - -class AcceptInvitationResponseModel(BaseModel): - workspace_id: str - workspace_name: str - role: str - - -class _NoopInvitationEmailService: - def send_workspace_invitation_email(self, **kwargs): - return False, "Email delivery is disabled for workspace compatibility routes" - - -@router.post("", response_model=WorkspaceResponseModel, status_code=status.HTTP_201_CREATED) -async def create_workspace( - request: CreateWorkspaceRequestModel, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), - user_repository: UserRepository = Depends(get_user_repository), -): - use_case = CreateWorkspaceUseCase( - workspace_repository=workspace_repository, - workspace_member_repository=workspace_member_repository, - user_repository=user_repository, - ) - response, error = use_case.execute( - CreateWorkspaceRequest( - name=request.name, - owner_user_id=authenticated_user.user.id, - subscription_plan=request.subscription_plan, - ) - ) - if error or response is None: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to create workspace") - - return WorkspaceResponseModel( - workspace_id=response.workspace_id, - name=response.name, - subscription_plan=response.subscription_plan, - max_projects=response.max_projects, - max_storage_gb=response.max_storage_gb, - ) - - -@router.get("", response_model=WorkspaceListResponseModel) -async def list_workspaces( - authenticated_user: AuthenticatedUser = Depends(get_current_user), - workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), -): - use_case = ListWorkspacesUseCase( - workspace_repository=workspace_repository, - workspace_member_repository=workspace_member_repository, - ) - response, error = use_case.execute(ListWorkspacesRequest(user_id=authenticated_user.user.id)) - if error or response is None: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to list workspaces") - - return WorkspaceListResponseModel( - workspaces=[ - WorkspaceSummaryModel( - workspace_id=workspace.workspace_id, - name=workspace.name, - subscription_plan=workspace.subscription_plan, - max_projects=workspace.max_projects, - max_storage_gb=workspace.max_storage_gb, - member_count=workspace.member_count, - user_role=workspace.user_role, - ) - for workspace in response.workspaces - ] - ) - - -@router.post("/invitations/{invitation_token}/accept", response_model=AcceptInvitationResponseModel) -async def accept_invitation( - invitation_token: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), - workspace_invitation_repository: WorkspaceInvitationRepository = Depends(get_workspace_invitation_repository), - user_repository: UserRepository = Depends(get_user_repository), -): - response, error = AcceptInvitationUseCase( - workspace_repository=workspace_repository, - workspace_member_repository=workspace_member_repository, - workspace_invitation_repository=workspace_invitation_repository, - user_repository=user_repository, - ).execute(AcceptInvitationRequest(invitation_token=invitation_token, user_id=authenticated_user.user.id)) - if error or response is None: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to accept invitation") - - return AcceptInvitationResponseModel( - workspace_id=response.workspace_id, - workspace_name=response.workspace_name, - role=response.role, - ) - - -@router.get("/{workspace_id}", response_model=WorkspaceDetailResponseModel) -async def get_workspace_detail( - workspace_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), -): - use_case = GetWorkspaceDetailUseCase( - workspace_repository=workspace_repository, - workspace_member_repository=workspace_member_repository, - ) - detail, error = use_case.execute( - GetWorkspaceDetailRequest(workspace_id=workspace_id, user_id=authenticated_user.user.id) - ) - if error or detail is None: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error or "Workspace access denied") - - return WorkspaceDetailResponseModel( - workspace_id=detail.workspace_id, - name=detail.name, - owner_user_id=detail.owner_user_id, - subscription_plan=detail.subscription_plan, - subscription_status=detail.subscription_status, - max_projects=detail.max_projects, - max_storage_gb=detail.max_storage_gb, - used_storage_gb=detail.used_storage_gb, - member_count=detail.member_count, - user_role=detail.user_role, - ) - - -@router.post( - "/{workspace_id}/members/invite", response_model=InviteMemberResponseModel, status_code=status.HTTP_201_CREATED -) -async def invite_member( - workspace_id: str, - request: InviteMemberRequestModel, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), - workspace_invitation_repository: WorkspaceInvitationRepository = Depends(get_workspace_invitation_repository), - user_repository: UserRepository = Depends(get_user_repository), -): - use_case = InviteMemberUseCase( - workspace_repository=workspace_repository, - workspace_member_repository=workspace_member_repository, - workspace_invitation_repository=workspace_invitation_repository, - user_repository=user_repository, - email_service=_NoopInvitationEmailService(), - ) - response, error = use_case.execute( - InviteMemberRequest( - workspace_id=workspace_id, - inviter_user_id=authenticated_user.user.id, - invitee_email=request.email, - role=request.role, - ) - ) - if error or response is None: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to invite member") - - return InviteMemberResponseModel( - invitation_id=response.invitation_id, - invitee_email=response.invitee_email, - role=response.role, - expires_at=response.expires_at.isoformat(), - ) - - -@router.get("/{workspace_id}/members", response_model=MemberListResponseModel) -async def list_members( - workspace_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), - user_repository: UserRepository = Depends(get_user_repository), -): - use_case = ListMembersUseCase( - workspace_repository=workspace_repository, - workspace_member_repository=workspace_member_repository, - user_repository=user_repository, - ) - response, error = use_case.execute( - ListMembersRequest(workspace_id=workspace_id, requester_user_id=authenticated_user.user.id) - ) - if error or response is None: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error or "Failed to list members") - - return MemberListResponseModel( - members=[ - MemberResponseModel( - member_id=member.member_id, - user_id=member.user_id, - username=member.username, - email=member.email, - display_name=member.display_name, - role=member.role, - invited_by=member.invited_by, - joined_at=member.joined_at.isoformat(), - ) - for member in response.members - ] - ) - - -@router.patch("/{workspace_id}/members/{user_id}/role", response_model=UpdateMemberRoleResponseModel) -async def update_member_role( - workspace_id: str, - user_id: str, - request: UpdateMemberRoleRequestModel, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), -): - use_case = UpdateMemberRoleUseCase( - workspace_repository=workspace_repository, - workspace_member_repository=workspace_member_repository, - ) - response, error = use_case.execute( - UpdateMemberRoleRequest( - workspace_id=workspace_id, - requester_user_id=authenticated_user.user.id, - target_user_id=user_id, - new_role=request.role, - ) - ) - if error or response is None: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to update member role") - - return UpdateMemberRoleResponseModel( - user_id=response.user_id, - old_role=response.old_role, - new_role=response.new_role, - ) - - -@router.delete("/{workspace_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def remove_member( - workspace_id: str, - user_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), -): - success, error = RemoveMemberUseCase( - workspace_repository=workspace_repository, - workspace_member_repository=workspace_member_repository, - ).execute( - RemoveMemberRequest( - workspace_id=workspace_id, - requester_user_id=authenticated_user.user.id, - target_user_id=user_id, - ) - ) - if not success: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to remove member") diff --git a/apps/api/app/config.py b/apps/api/app/config.py index 39c9ced1b..c4c3d4543 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -7,7 +7,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): APP_NAME: str = "xiaoxia-saas" - APP_VERSION: str = "0.1.0" + APP_VERSION: str = "0.1.61" ENVIRONMENT: str = "development" DEBUG: bool = True @@ -22,7 +22,7 @@ class Settings(BaseSettings): DATABASE_POOL_SIZE: int = 20 DATABASE_MAX_OVERFLOW: int = 40 DATABASE_POOL_TIMEOUT: int = 30 - DATABASE_POOL_RECYCLE: int = 3600 + DATABASE_POOL_RECYLE: int = 3600 USE_IN_MEMORY_DB: bool = False AUTO_CREATE_SCHEMA: bool = False diff --git a/apps/api/app/schemas/asset.py b/apps/api/app/schemas/asset.py index 21127f98a..5749430d5 100644 --- a/apps/api/app/schemas/asset.py +++ b/apps/api/app/schemas/asset.py @@ -2,7 +2,6 @@ from pydantic import BaseModel, Field class CreateAssetRequest(BaseModel): - workspace_id: str = Field(..., min_length=1) project_id: str = Field(..., min_length=1) library_id: str = Field(..., min_length=1) name: str = Field(..., min_length=1, max_length=100) @@ -28,7 +27,6 @@ class UpdateAssetReviewRequest(BaseModel): class AssetResponse(BaseModel): id: str - workspace_id: str project_id: str library_id: str name: str diff --git a/apps/api/app/schemas/asset_library.py b/apps/api/app/schemas/asset_library.py index bb7b99414..0277fe886 100644 --- a/apps/api/app/schemas/asset_library.py +++ b/apps/api/app/schemas/asset_library.py @@ -2,7 +2,6 @@ from pydantic import BaseModel, Field class CreateAssetLibraryRequest(BaseModel): - workspace_id: str = Field(..., min_length=1) project_id: str = Field(..., min_length=1) name: str = Field(..., min_length=1, max_length=100) kind: str = Field(..., pattern="^(video|voice|image)$") @@ -10,7 +9,6 @@ class CreateAssetLibraryRequest(BaseModel): class AssetLibraryResponse(BaseModel): id: str - workspace_id: str project_id: str name: str kind: str diff --git a/apps/api/app/schemas/classification_job.py b/apps/api/app/schemas/classification_job.py index af4387fbb..f5355bc3f 100644 --- a/apps/api/app/schemas/classification_job.py +++ b/apps/api/app/schemas/classification_job.py @@ -2,14 +2,12 @@ from pydantic import BaseModel, Field class SubmitClassificationJobRequest(BaseModel): - workspace_id: str = Field(..., min_length=1) project_id: str = Field(..., min_length=1) asset_id: str = Field(..., min_length=1) class ClassificationJobResponse(BaseModel): id: str - workspace_id: str project_id: str asset_id: str status: str diff --git a/apps/api/app/schemas/generated_video.py b/apps/api/app/schemas/generated_video.py index 4155f8b33..5f800bd87 100644 --- a/apps/api/app/schemas/generated_video.py +++ b/apps/api/app/schemas/generated_video.py @@ -11,7 +11,6 @@ class UpdateGeneratedVideoReviewRequest(BaseModel): class GeneratedVideoResponse(BaseModel): id: str - workspace_id: str project_id: str generation_task_id: str name: str diff --git a/apps/api/app/schemas/generation_task.py b/apps/api/app/schemas/generation_task.py index 372680677..7ab783be4 100644 --- a/apps/api/app/schemas/generation_task.py +++ b/apps/api/app/schemas/generation_task.py @@ -2,7 +2,6 @@ from pydantic import BaseModel, Field class CreateGenerationTaskRequest(BaseModel): - workspace_id: str = Field(..., min_length=1) project_id: str = Field(..., min_length=1) asset_library_id: str = Field(..., min_length=1) strategy_id: str = "" @@ -13,7 +12,6 @@ class CreateGenerationTaskRequest(BaseModel): class GenerationTaskResponse(BaseModel): id: str - workspace_id: str project_id: str asset_library_id: str strategy_id: str diff --git a/apps/api/app/schemas/ingest_job.py b/apps/api/app/schemas/ingest_job.py index ca1ec8387..c20c3820c 100644 --- a/apps/api/app/schemas/ingest_job.py +++ b/apps/api/app/schemas/ingest_job.py @@ -2,7 +2,6 @@ from pydantic import BaseModel, Field class SubmitIngestJobRequest(BaseModel): - workspace_id: str = Field(..., min_length=1) project_id: str = Field(..., min_length=1) library_id: str = Field(..., min_length=1) storage_key: str = Field(..., min_length=1, max_length=255) @@ -10,7 +9,6 @@ class SubmitIngestJobRequest(BaseModel): class IngestJobResponse(BaseModel): id: str - workspace_id: str project_id: str library_id: str storage_key: str diff --git a/apps/api/app/schemas/project.py b/apps/api/app/schemas/project.py index f3cdb4974..f03d99148 100644 --- a/apps/api/app/schemas/project.py +++ b/apps/api/app/schemas/project.py @@ -2,16 +2,16 @@ from pydantic import BaseModel, Field class CreateProjectRequest(BaseModel): - workspace_id: str = Field(..., min_length=1) name: str = Field(..., min_length=1, max_length=100) description: str = Field(default="", max_length=500) class ProjectResponse(BaseModel): id: str - workspace_id: str + owner_user_id: str name: str description: str + shared_users: list[str] = [] class ListProjectsResponse(BaseModel): diff --git a/apps/api/app/schemas/upload.py b/apps/api/app/schemas/upload.py index 8c619eb42..8e36febfd 100644 --- a/apps/api/app/schemas/upload.py +++ b/apps/api/app/schemas/upload.py @@ -8,7 +8,6 @@ class UploadAssetResponse(BaseModel): class DirectUploadPrepareRequest(BaseModel): - workspace_id: str = Field(..., min_length=1) project_id: str = Field(..., min_length=1) library_id: str = Field(..., min_length=1) filename: str = Field(..., min_length=1, max_length=255) @@ -26,7 +25,6 @@ class DirectUploadPrepareResponse(BaseModel): class DirectUploadCompleteRequest(BaseModel): - workspace_id: str = Field(..., min_length=1) project_id: str = Field(..., min_length=1) library_id: str = Field(..., min_length=1) storage_key: str = Field(..., min_length=1, max_length=255) diff --git a/packages/adapters/in_memory/project_repository.py b/packages/adapters/in_memory/project_repository.py index fd4589ccf..8df5e8359 100644 --- a/packages/adapters/in_memory/project_repository.py +++ b/packages/adapters/in_memory/project_repository.py @@ -5,19 +5,27 @@ from packages.domain import Project class InMemoryProjectRepository: def __init__(self): - self._items: dict[str, list[Project]] = {} + self._items: dict[str, Project] = {} # project_id -> Project - def list_by_workspace(self, workspace_id: str) -> list[Project]: - return list(self._items.get(workspace_id, [])) + def save(self, project: Project) -> Project: + self._items[project.id] = project + return project def find_by_id(self, project_id: str) -> Project | None: - for items in self._items.values(): - for project in items: - if project.id == project_id: - return project - return None + return self._items.get(project_id) - def create(self, project: Project) -> Project: - items = self._items.setdefault(project.workspace_id, []) - items.append(project) - return project + def find_by_owner_user_id(self, owner_user_id: str) -> list[Project]: + return [p for p in self._items.values() if p.owner_user_id == owner_user_id] + + def find_accessible_projects(self, user_id: str) -> list[Project]: + """查找用户可访问的所有项目(自己拥有的 + 被共享的)""" + return [p for p in self._items.values() if p.can_access(user_id)] + + def count_by_owner(self, owner_user_id: str) -> int: + return len([p for p in self._items.values() if p.owner_user_id == owner_user_id]) + + def delete(self, project_id: str) -> bool: + if project_id in self._items: + del self._items[project_id] + return True + return False diff --git a/packages/adapters/in_memory/workspace_invitation_repository.py b/packages/adapters/in_memory/workspace_invitation_repository.py deleted file mode 100644 index c94433066..000000000 --- a/packages/adapters/in_memory/workspace_invitation_repository.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -WorkspaceInvitation 仓储 In-Memory 实现 -""" - -from typing import Dict, Optional - -from packages.domain.entities import InvitationStatus, WorkspaceInvitation -from packages.ports.workspace_invitation_repository import WorkspaceInvitationRepository - - -class InMemoryWorkspaceInvitationRepository(WorkspaceInvitationRepository): - """WorkspaceInvitation 仓储内存实现""" - - def __init__(self): - self._invitations: Dict[str, WorkspaceInvitation] = {} - self._token_index: Dict[str, str] = {} # token -> invitation_id - self._workspace_email_index: Dict[tuple[str, str], str] = {} # (workspace_id, email) -> invitation_id - - def save(self, invitation: WorkspaceInvitation) -> None: - """保存邀请""" - self._invitations[invitation.id] = invitation - self._token_index[invitation.invitation_token] = invitation.id - - # 只为 pending 状态的邀请建立索引 - if invitation.status == InvitationStatus.PENDING: - key = (invitation.workspace_id, invitation.invitee_email.lower()) - self._workspace_email_index[key] = invitation.id - else: - # 如果状态改变,清理索引 - key = (invitation.workspace_id, invitation.invitee_email.lower()) - self._workspace_email_index.pop(key, None) - - def find_by_id(self, invitation_id: str) -> Optional[WorkspaceInvitation]: - """根据 ID 查找邀请""" - return self._invitations.get(invitation_id) - - def find_by_token(self, token: str) -> Optional[WorkspaceInvitation]: - """根据令牌查找邀请""" - invitation_id = self._token_index.get(token) - if invitation_id: - return self._invitations.get(invitation_id) - return None - - def find_pending_by_workspace_and_email( - self, - workspace_id: str, - email: str, - ) -> Optional[WorkspaceInvitation]: - """查找 workspace 和邮箱的待处理邀请""" - key = (workspace_id, email.lower()) - invitation_id = self._workspace_email_index.get(key) - if invitation_id: - return self._invitations.get(invitation_id) - return None - - def delete(self, invitation_id: str) -> bool: - """删除邀请""" - invitation = self._invitations.get(invitation_id) - if not invitation: - return False - - # 清理索引 - self._token_index.pop(invitation.invitation_token, None) - key = (invitation.workspace_id, invitation.invitee_email.lower()) - self._workspace_email_index.pop(key, None) - - # 删除邀请 - del self._invitations[invitation_id] - return True diff --git a/packages/adapters/in_memory/workspace_member_repository.py b/packages/adapters/in_memory/workspace_member_repository.py deleted file mode 100644 index a0f83a161..000000000 --- a/packages/adapters/in_memory/workspace_member_repository.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -WorkspaceMember 仓储 In-Memory 实现 -""" - -from typing import Dict, List, Optional - -from packages.domain.entities import WorkspaceMember -from packages.ports.workspace_member_repository import WorkspaceMemberRepository - - -class InMemoryWorkspaceMemberRepository(WorkspaceMemberRepository): - """WorkspaceMember 仓储内存实现""" - - def __init__(self): - self._members: Dict[str, WorkspaceMember] = {} - self._workspace_user_index: Dict[tuple[str, str], str] = {} # (workspace_id, user_id) -> member_id - self._user_index: Dict[str, List[str]] = {} # user_id -> [member_ids] - self._workspace_index: Dict[str, List[str]] = {} # workspace_id -> [member_ids] - - def save(self, member: WorkspaceMember) -> None: - """保存成员""" - self._members[member.id] = member - - # 更新索引 - key = (member.workspace_id, member.user_id) - self._workspace_user_index[key] = member.id - - if member.user_id not in self._user_index: - self._user_index[member.user_id] = [] - if member.id not in self._user_index[member.user_id]: - self._user_index[member.user_id].append(member.id) - - if member.workspace_id not in self._workspace_index: - self._workspace_index[member.workspace_id] = [] - if member.id not in self._workspace_index[member.workspace_id]: - self._workspace_index[member.workspace_id].append(member.id) - - def find_by_id(self, member_id: str) -> Optional[WorkspaceMember]: - """根据 ID 查找成员""" - return self._members.get(member_id) - - def find_by_workspace_and_user( - self, - workspace_id: str, - user_id: str, - ) -> Optional[WorkspaceMember]: - """根据 workspace 和 user 查找成员""" - key = (workspace_id, user_id) - member_id = self._workspace_user_index.get(key) - if member_id: - return self._members.get(member_id) - return None - - def find_by_user(self, user_id: str) -> List[WorkspaceMember]: - """查找用户的所有成员记录""" - member_ids = self._user_index.get(user_id, []) - return [self._members[mid] for mid in member_ids if mid in self._members] - - def find_by_workspace(self, workspace_id: str) -> List[WorkspaceMember]: - """查找 workspace 的所有成员""" - member_ids = self._workspace_index.get(workspace_id, []) - return [self._members[mid] for mid in member_ids if mid in self._members] - - def count_by_workspace(self, workspace_id: str) -> int: - """统计 workspace 的成员数量""" - return len(self._workspace_index.get(workspace_id, [])) - - def delete(self, member_id: str) -> bool: - """删除成员""" - member = self._members.get(member_id) - if not member: - return False - - # 清理索引 - key = (member.workspace_id, member.user_id) - self._workspace_user_index.pop(key, None) - - if member.user_id in self._user_index: - self._user_index[member.user_id].remove(member_id) - - if member.workspace_id in self._workspace_index: - self._workspace_index[member.workspace_id].remove(member_id) - - # 删除成员 - del self._members[member_id] - return True diff --git a/packages/adapters/in_memory/workspace_repository.py b/packages/adapters/in_memory/workspace_repository.py deleted file mode 100644 index 3c3f6606e..000000000 --- a/packages/adapters/in_memory/workspace_repository.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Workspace 仓储 In-Memory 实现 -""" - -from typing import Dict, Optional - -from packages.domain.entities import Workspace -from packages.ports.workspace_repository import WorkspaceRepository - - -class InMemoryWorkspaceRepository(WorkspaceRepository): - """Workspace 仓储内存实现""" - - def __init__(self): - self._workspaces: Dict[str, Workspace] = {} - - def save(self, workspace: Workspace) -> None: - """保存 Workspace""" - self._workspaces[workspace.id] = workspace - - def find_by_id(self, workspace_id: str) -> Optional[Workspace]: - """根据 ID 查找 Workspace""" - return self._workspaces.get(workspace_id) - - def delete(self, workspace_id: str) -> bool: - """删除 Workspace""" - if workspace_id in self._workspaces: - del self._workspaces[workspace_id] - return True - return False diff --git a/packages/adapters/sqlalchemy_impl/asset_library_repository.py b/packages/adapters/sqlalchemy_impl/asset_library_repository.py index 8928a1be3..5b418084c 100644 --- a/packages/adapters/sqlalchemy_impl/asset_library_repository.py +++ b/packages/adapters/sqlalchemy_impl/asset_library_repository.py @@ -14,7 +14,6 @@ class SQLAlchemyAssetLibraryRepository: return None return AssetLibrary( id=model.id, - workspace_id=model.workspace_id, project_id=model.project_id, name=model.name, kind=AssetLibraryKind(model.kind), @@ -24,15 +23,16 @@ class SQLAlchemyAssetLibraryRepository: updated_at=model.updated_at, ) - def list_by_project(self, project_id: str, kind: AssetLibraryKind | None = None) -> list[AssetLibrary]: - query = self.session.query(AssetLibraryModel).filter(AssetLibraryModel.project_id == project_id) - if kind is not None: - query = query.filter(AssetLibraryModel.kind == kind.value) - models = query.all() + def find_by_id(self, library_id: str) -> AssetLibrary | None: + return self.get(library_id) + + def find_by_project(self, project_id: str) -> list[AssetLibrary]: + models = self.session.query(AssetLibraryModel).filter( + AssetLibraryModel.project_id == project_id + ).all() return [ AssetLibrary( id=model.id, - workspace_id=model.workspace_id, project_id=model.project_id, name=model.name, kind=AssetLibraryKind(model.kind), @@ -47,7 +47,6 @@ class SQLAlchemyAssetLibraryRepository: def create(self, library: AssetLibrary) -> AssetLibrary: model = AssetLibraryModel( id=library.id, - workspace_id=library.workspace_id, project_id=library.project_id, name=library.name, kind=library.kind.value, @@ -59,3 +58,45 @@ class SQLAlchemyAssetLibraryRepository: self.session.add(model) self.session.commit() return library + + def update(self, library: AssetLibrary) -> AssetLibrary: + model = self.session.query(AssetLibraryModel).filter( + AssetLibraryModel.id == library.id + ).first() + if model: + model.project_id = library.project_id + model.name = library.name + model.kind = library.kind.value + model.asset_count = library.asset_count + model.total_size = library.total_size + model.updated_at = library.updated_at + self.session.commit() + return library + + def delete(self, library_id: str) -> bool: + model = self.session.query(AssetLibraryModel).filter( + AssetLibraryModel.id == library_id + ).first() + if model: + self.session.delete(model) + self.session.commit() + return True + return False + + async def increment_asset_count(self, library_id: str, size_delta: int) -> None: + model = self.session.query(AssetLibraryModel).filter( + AssetLibraryModel.id == library_id + ).first() + if model: + model.asset_count = (model.asset_count or 0) + 1 + model.total_size = (model.total_size or 0) + size_delta + self.session.commit() + + async def decrement_asset_count(self, library_id: str, size_delta: int) -> None: + model = self.session.query(AssetLibraryModel).filter( + AssetLibraryModel.id == library_id + ).first() + if model: + model.asset_count = max(0, (model.asset_count or 0) - 1) + model.total_size = max(0, (model.total_size or 0) - size_delta) + self.session.commit() diff --git a/packages/adapters/sqlalchemy_impl/asset_repository.py b/packages/adapters/sqlalchemy_impl/asset_repository.py index c08854703..063edf77e 100644 --- a/packages/adapters/sqlalchemy_impl/asset_repository.py +++ b/packages/adapters/sqlalchemy_impl/asset_repository.py @@ -11,21 +11,41 @@ class SQLAlchemyAssetRepository: def __init__(self, session: Session): self.session = session - def list_by_library(self, library_id: str) -> list[Asset]: - models = self.session.query(AssetModel).filter(AssetModel.asset_library_id == library_id).all() + async def find_by_library( + self, + library_id: str, + skip: int = 0, + limit: int = 100, + ) -> list[Asset]: + models = self.session.query(AssetModel).filter( + AssetModel.asset_library_id == library_id + ).offset(skip).limit(limit).all() return [self._to_domain(model) for model in models] - def get(self, asset_id: str) -> Asset | None: + async def find_by_project( + self, + project_id: str, + skip: int = 0, + limit: int = 100, + ) -> list[Asset]: + models = self.session.query(AssetModel).filter( + AssetModel.project_id == project_id + ).offset(skip).limit(limit).all() + return [self._to_domain(model) for model in models] + + async def find_by_id(self, asset_id: str) -> Asset | None: model = self.session.query(AssetModel).filter(AssetModel.id == asset_id).first() if model is None: return None return self._to_domain(model) - def create(self, asset: Asset) -> Asset: + def get(self, asset_id: str) -> Asset | None: + return self.find_by_id(asset_id) + + async def create(self, asset: Asset) -> Asset: now = datetime.now(timezone.utc) model = AssetModel( id=asset.id, - workspace_id=asset.workspace_id, project_id=asset.project_id, asset_library_id=asset.library_id, name=asset.name, @@ -50,7 +70,7 @@ class SQLAlchemyAssetRepository: self.session.commit() return asset - def update(self, asset: Asset) -> Asset: + async def update(self, asset: Asset) -> Asset: model = self.session.query(AssetModel).filter(AssetModel.id == asset.id).first() if model is None: raise ValueError(f"Asset {asset.id} not found") @@ -72,6 +92,19 @@ class SQLAlchemyAssetRepository: self.session.commit() return asset + async def delete(self, asset_id: str) -> bool: + model = self.session.query(AssetModel).filter(AssetModel.id == asset_id).first() + if model: + self.session.delete(model) + self.session.commit() + return True + return False + + async def count_by_project(self, project_id: str) -> int: + return self.session.query(AssetModel).filter( + AssetModel.project_id == project_id + ).count() + def _to_domain(self, model: AssetModel) -> Asset: metadata = {} if model.classification_result: @@ -88,7 +121,6 @@ class SQLAlchemyAssetRepository: }.get(mime_type, mime_type) return Asset( id=model.id, - workspace_id=model.workspace_id, project_id=model.project_id, library_id=model.asset_library_id, name=model.name, diff --git a/packages/adapters/sqlalchemy_impl/classification_job_repository.py b/packages/adapters/sqlalchemy_impl/classification_job_repository.py index 88711564c..29dc09f75 100644 --- a/packages/adapters/sqlalchemy_impl/classification_job_repository.py +++ b/packages/adapters/sqlalchemy_impl/classification_job_repository.py @@ -1,7 +1,7 @@ from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl.models import ClassificationJobModel -from packages.domain import ClassificationJob, ClassificationJobStatus +from packages.domain import ClassificationJob class SQLAlchemyClassificationJobRepository: @@ -11,10 +11,9 @@ class SQLAlchemyClassificationJobRepository: def create(self, job: ClassificationJob) -> ClassificationJob: model = ClassificationJobModel( id=job.id, - workspace_id=job.workspace_id, project_id=job.project_id, asset_id=job.asset_id, - status=job.status.value, + status=job.status, classification=job.classification, confidence=job.confidence, error_message=job.error_message, @@ -31,10 +30,9 @@ class SQLAlchemyClassificationJobRepository: return None return ClassificationJob( id=model.id, - workspace_id=model.workspace_id, project_id=model.project_id, asset_id=model.asset_id, - status=ClassificationJobStatus(model.status), + status=model.status, classification=model.classification, confidence=model.confidence, error_message=model.error_message, @@ -46,7 +44,7 @@ class SQLAlchemyClassificationJobRepository: model = self.session.query(ClassificationJobModel).filter(ClassificationJobModel.id == job.id).first() if model is None: raise ValueError(f"ClassificationJob {job.id} not found") - model.status = job.status.value + model.status = job.status model.classification = job.classification model.confidence = job.confidence model.error_message = job.error_message diff --git a/packages/adapters/sqlalchemy_impl/generated_video_repository.py b/packages/adapters/sqlalchemy_impl/generated_video_repository.py index dfe6d4042..830faaed5 100644 --- a/packages/adapters/sqlalchemy_impl/generated_video_repository.py +++ b/packages/adapters/sqlalchemy_impl/generated_video_repository.py @@ -13,7 +13,6 @@ class SQLAlchemyGeneratedVideoRepository: def create(self, video: GeneratedVideo) -> GeneratedVideo: model = GeneratedVideoModel( id=video.id, - workspace_id=video.workspace_id, project_id=video.project_id, generation_task_id=video.generation_task_id, name=video.name, @@ -43,7 +42,6 @@ class SQLAlchemyGeneratedVideoRepository: return None return GeneratedVideo( id=model.id, - workspace_id=model.workspace_id, project_id=model.project_id, generation_task_id=model.generation_task_id, name=model.name, diff --git a/packages/adapters/sqlalchemy_impl/generation_task_repository.py b/packages/adapters/sqlalchemy_impl/generation_task_repository.py index 2358ac690..91f75449e 100644 --- a/packages/adapters/sqlalchemy_impl/generation_task_repository.py +++ b/packages/adapters/sqlalchemy_impl/generation_task_repository.py @@ -1,7 +1,7 @@ from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel -from packages.domain import GenerationTask, GenerationTaskStatus +from packages.domain import GenerationTask class SQLAlchemyGenerationTaskRepository: @@ -11,13 +11,12 @@ class SQLAlchemyGenerationTaskRepository: def create(self, task: GenerationTask) -> GenerationTask: model = GenerationTaskModel( id=task.id, - workspace_id=task.workspace_id, project_id=task.project_id, strategy_id=task.strategy_id, asset_library_id=task.asset_library_id, voice_library_id=task.voice_library_id, edit_plan_id=task.edit_plan_id, - status=task.status.value, + status=task.status, progress=task.progress, result_count=task.result_count, error_message=task.error_message, @@ -36,13 +35,12 @@ class SQLAlchemyGenerationTaskRepository: return None return GenerationTask( id=model.id, - workspace_id=model.workspace_id, project_id=model.project_id, strategy_id=model.strategy_id, asset_library_id=model.asset_library_id, voice_library_id=model.voice_library_id, edit_plan_id=getattr(model, "edit_plan_id", "") or "", - status=GenerationTaskStatus(model.status), + status=model.status, progress=model.progress, result_count=int(model.result_count or 0), error_message=model.error_message, @@ -62,7 +60,7 @@ class SQLAlchemyGenerationTaskRepository: raise ValueError(f"GenerationTask {task.id} not found") model.voice_library_id = task.voice_library_id model.edit_plan_id = task.edit_plan_id - model.status = task.status.value + model.status = task.status model.progress = task.progress model.result_count = task.result_count model.error_message = task.error_message diff --git a/packages/adapters/sqlalchemy_impl/ingest_job_repository.py b/packages/adapters/sqlalchemy_impl/ingest_job_repository.py index 81d3db3ec..ed3a2ff49 100644 --- a/packages/adapters/sqlalchemy_impl/ingest_job_repository.py +++ b/packages/adapters/sqlalchemy_impl/ingest_job_repository.py @@ -11,7 +11,6 @@ class SQLAlchemyIngestJobRepository: def create(self, job: IngestJob) -> IngestJob: model = IngestJobModel( id=job.id, - workspace_id=job.workspace_id, project_id=job.project_id, library_id=job.library_id, storage_key=job.storage_key, @@ -31,7 +30,6 @@ class SQLAlchemyIngestJobRepository: return None return IngestJob( id=model.id, - workspace_id=model.workspace_id, project_id=model.project_id, library_id=model.library_id, storage_key=model.storage_key, diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index fc9fb98f5..129ee9b06 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint, create_engine +from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, Text, JSON, UniqueConstraint, create_engine from sqlalchemy.orm import declarative_base Base = declarative_base() @@ -20,58 +20,25 @@ class UserModel(Base): password_reset_expires_at = Column(DateTime, nullable=True) last_login_at = Column(DateTime, nullable=True) last_login_ip = Column(String(50), nullable=True) - created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) - - -class WorkspaceModel(Base): - __tablename__ = "workspaces" - - id = Column(String(36), primary_key=True) - name = Column(String(100), nullable=False) - owner_user_id = Column(String(36), nullable=False, index=True) + # 订阅相关字段 (移到 User 级别) subscription_plan = Column(String(20), nullable=False, default="free") subscription_status = Column(String(20), nullable=False, default="active") subscription_expires_at = Column(DateTime, nullable=True) + # 配额限制 (移到 User 级别) max_projects = Column(Float, nullable=False, default=3) max_storage_gb = Column(Float, nullable=False, default=10) used_storage_gb = Column(Float, nullable=False, default=0.0) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) -class WorkspaceMemberModel(Base): - __tablename__ = "workspace_members" - __table_args__ = (UniqueConstraint("workspace_id", "user_id", name="uq_workspace_members_workspace_user"),) - - id = Column(String(36), primary_key=True) - workspace_id = Column(String(36), nullable=False, index=True) - user_id = Column(String(36), nullable=False, index=True) - role = Column(String(20), nullable=False) - invited_by = Column(String(36), nullable=True) - joined_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) - - -class WorkspaceInvitationModel(Base): - __tablename__ = "workspace_invitations" - - id = Column(String(36), primary_key=True) - workspace_id = Column(String(36), nullable=False, index=True) - inviter_user_id = Column(String(36), nullable=False) - invitee_email = Column(String(255), nullable=False, index=True) - role = Column(String(20), nullable=False) - invitation_token = Column(String(255), nullable=False, unique=True, index=True) - status = Column(String(20), nullable=False, default="pending", index=True) - expires_at = Column(DateTime, nullable=True) - accepted_at = Column(DateTime, nullable=True) - created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) - - class ProjectModel(Base): __tablename__ = "projects" id = Column(String(32), primary_key=True) - workspace_id = Column(String(32), nullable=False, index=True) + owner_user_id = Column(String(32), nullable=False, index=True) name = Column(String(100), nullable=False) description = Column(Text, nullable=False, default="") + shared_users = Column(JSON, nullable=False, default=list) # 被共享的用户 ID 列表 created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) @@ -79,7 +46,6 @@ class AssetLibraryModel(Base): __tablename__ = "asset_libraries" id = Column(String(36), primary_key=True) - workspace_id = Column(String(36), nullable=False, index=True) project_id = Column(String(36), nullable=True, index=True) name = Column(String(255), nullable=False) kind = Column(String(20), nullable=False, index=True) @@ -93,7 +59,6 @@ class AssetModel(Base): __tablename__ = "assets" id = Column(String(36), primary_key=True) - workspace_id = Column(String(36), nullable=False, index=True) project_id = Column(String(36), nullable=False, index=True) asset_library_id = Column(String(36), nullable=False, index=True) name = Column(String(500), nullable=False) @@ -119,7 +84,6 @@ class ProjectTitleModel(Base): __tablename__ = "project_titles" id = Column(String(36), primary_key=True) - workspace_id = Column(String(36), nullable=False, index=True) project_id = Column(String(36), nullable=False, index=True) text = Column(String(200), nullable=False) category = Column(String(50), nullable=False, default="default", index=True) @@ -135,7 +99,6 @@ class EditTemplateModel(Base): __tablename__ = "edit_templates" id = Column(String(32), primary_key=True) - workspace_id = Column(String(32), nullable=False, index=True) project_id = Column(String(32), nullable=False, index=True) name = Column(String(120), nullable=False) description = Column(Text, nullable=False, default="") @@ -151,7 +114,6 @@ class EditPlanModel(Base): __tablename__ = "edit_plans" id = Column(String(32), primary_key=True) - workspace_id = Column(String(32), nullable=False, index=True) project_id = Column(String(32), nullable=False, index=True) template_id = Column(String(32), nullable=False, index=True) asset_library_id = Column(String(32), nullable=False, index=True) @@ -181,7 +143,6 @@ class IngestJobModel(Base): __tablename__ = "ingest_jobs" id = Column(String(32), primary_key=True) - workspace_id = Column(String(32), nullable=False, index=True) project_id = Column(String(32), nullable=False, index=True) library_id = Column(String(32), nullable=False, index=True) storage_key = Column(String(255), nullable=False) @@ -196,7 +157,6 @@ class ClassificationJobModel(Base): __tablename__ = "classification_jobs" id = Column(String(32), primary_key=True) - workspace_id = Column(String(32), nullable=False, index=True) project_id = Column(String(32), nullable=False, index=True) asset_id = Column(String(32), nullable=False, index=True) status = Column(String(20), nullable=False, default="pending") @@ -211,7 +171,6 @@ class GenerationTaskModel(Base): __tablename__ = "generation_tasks" id = Column(String(32), primary_key=True) - workspace_id = Column(String(32), nullable=False, index=True) project_id = Column(String(32), nullable=False, index=True) strategy_id = Column(String(32), nullable=False, default="") asset_library_id = Column(String(32), nullable=False, index=True) @@ -232,7 +191,6 @@ class GeneratedVideoModel(Base): __tablename__ = "generated_videos" id = Column(String(32), primary_key=True) - workspace_id = Column(String(32), nullable=False, index=True) project_id = Column(String(32), nullable=False, index=True) generation_task_id = Column(String(32), nullable=False, index=True) name = Column(String(255), nullable=False) @@ -259,7 +217,6 @@ class TaskModel(Base): id = Column(String(32), primary_key=True) project_id = Column(String(32), nullable=False, index=True) - workspace_id = Column(String(32), nullable=False, index=True) name = Column(String(200), nullable=False) description = Column(Text, nullable=False, default="") status = Column(String(20), nullable=False, default="pending", index=True) @@ -281,7 +238,6 @@ class MilestoneModel(Base): id = Column(String(32), primary_key=True) project_id = Column(String(32), nullable=False, index=True) - workspace_id = Column(String(32), nullable=False, index=True) name = Column(String(200), nullable=False) description = Column(Text, nullable=False, default="") target_date = Column(DateTime, nullable=True) @@ -297,7 +253,6 @@ class TaskIssueModel(Base): id = Column(String(32), primary_key=True) task_id = Column(String(32), nullable=False, index=True) project_id = Column(String(32), nullable=False, index=True) - workspace_id = Column(String(32), nullable=False, index=True) title = Column(String(200), nullable=False) description = Column(Text, nullable=False, default="") resolved = Column(Boolean, nullable=False, default=False) diff --git a/packages/adapters/sqlalchemy_impl/project_repository.py b/packages/adapters/sqlalchemy_impl/project_repository.py index 7bc78f603..963fd0681 100644 --- a/packages/adapters/sqlalchemy_impl/project_repository.py +++ b/packages/adapters/sqlalchemy_impl/project_repository.py @@ -11,15 +11,33 @@ class SQLAlchemyProjectRepository: def _to_entity(self, model: ProjectModel) -> Project: return Project( id=model.id, - workspace_id=model.workspace_id, + owner_user_id=model.owner_user_id, name=model.name, description=model.description, + shared_users=model.shared_users or [], created_at=model.created_at, ) - def list_by_workspace(self, workspace_id: str) -> list[Project]: - models = self.session.query(ProjectModel).filter(ProjectModel.workspace_id == workspace_id).all() - return [self._to_entity(model) for model in models] + def save(self, project: Project) -> Project: + """保存项目(创建或更新)""" + existing = self.session.query(ProjectModel).filter(ProjectModel.id == project.id).first() + if existing: + existing.owner_user_id = project.owner_user_id + existing.name = project.name + existing.description = project.description + existing.shared_users = project.shared_users + else: + model = ProjectModel( + id=project.id, + owner_user_id=project.owner_user_id, + name=project.name, + description=project.description, + shared_users=project.shared_users, + created_at=project.created_at, + ) + self.session.add(model) + self.session.commit() + return project def find_by_id(self, project_id: str) -> Project | None: model = self.session.query(ProjectModel).filter(ProjectModel.id == project_id).first() @@ -27,14 +45,36 @@ class SQLAlchemyProjectRepository: return None return self._to_entity(model) - def create(self, project: Project) -> Project: - model = ProjectModel( - id=project.id, - workspace_id=project.workspace_id, - name=project.name, - description=project.description, - created_at=project.created_at, - ) - self.session.add(model) + def find_by_owner_user_id(self, owner_user_id: str) -> list[Project]: + """根据所有者用户 ID 查找项目""" + models = self.session.query(ProjectModel).filter( + ProjectModel.owner_user_id == owner_user_id + ).all() + return [self._to_entity(model) for model in models] + + def find_accessible_projects(self, user_id: str) -> list[Project]: + """查找用户可访问的所有项目(自己拥有的 + 被共享的)""" + from sqlalchemy import or_ + + models = self.session.query(ProjectModel).filter( + or_( + ProjectModel.owner_user_id == user_id, + ProjectModel.shared_users.contains([user_id]) + ) + ).all() + return [self._to_entity(model) for model in models] + + def count_by_owner(self, owner_user_id: str) -> int: + """统计用户的项目数量""" + return self.session.query(ProjectModel).filter( + ProjectModel.owner_user_id == owner_user_id + ).count() + + def delete(self, project_id: str) -> bool: + """删除项目""" + model = self.session.query(ProjectModel).filter(ProjectModel.id == project_id).first() + if model is None: + return False + self.session.delete(model) self.session.commit() - return project + return True diff --git a/packages/adapters/sqlalchemy_impl/workspace_invitation_repository.py b/packages/adapters/sqlalchemy_impl/workspace_invitation_repository.py deleted file mode 100644 index 9958be1da..000000000 --- a/packages/adapters/sqlalchemy_impl/workspace_invitation_repository.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -from sqlalchemy.orm import Session - -from packages.adapters.sqlalchemy_impl.models import WorkspaceInvitationModel -from packages.domain.entities import WorkspaceInvitation -from packages.ports.workspace_invitation_repository import WorkspaceInvitationRepository - - -class SQLAlchemyWorkspaceInvitationRepository(WorkspaceInvitationRepository): - def __init__(self, session: Session): - self.session = session - - def save(self, invitation: WorkspaceInvitation) -> None: - model = self.session.get(WorkspaceInvitationModel, invitation.id) - if model is None: - model = WorkspaceInvitationModel(id=invitation.id) - self.session.add(model) - - model.workspace_id = invitation.workspace_id - model.inviter_user_id = invitation.inviter_user_id - model.invitee_email = invitation.invitee_email.strip().lower() - model.role = str(invitation.role) - model.invitation_token = invitation.invitation_token - model.status = str(invitation.status) - model.expires_at = invitation.expires_at - model.accepted_at = invitation.accepted_at - model.created_at = invitation.created_at - - self.session.commit() - self.session.refresh(model) - - def find_by_id(self, invitation_id: str) -> WorkspaceInvitation | None: - return self._to_entity(self.session.get(WorkspaceInvitationModel, invitation_id)) - - def find_by_token(self, token: str) -> WorkspaceInvitation | None: - model = ( - self.session.query(WorkspaceInvitationModel) - .filter(WorkspaceInvitationModel.invitation_token == token) - .first() - ) - return self._to_entity(model) - - def find_pending_by_workspace_and_email(self, workspace_id: str, email: str) -> WorkspaceInvitation | None: - model = ( - self.session.query(WorkspaceInvitationModel) - .filter( - WorkspaceInvitationModel.workspace_id == workspace_id, - WorkspaceInvitationModel.invitee_email == email.strip().lower(), - WorkspaceInvitationModel.status == "pending", - ) - .first() - ) - return self._to_entity(model) - - def delete(self, invitation_id: str) -> bool: - model = self.session.get(WorkspaceInvitationModel, invitation_id) - if model is None: - return False - self.session.delete(model) - self.session.commit() - return True - - @staticmethod - def _to_entity(model: WorkspaceInvitationModel | None) -> WorkspaceInvitation | None: - if model is None: - return None - return WorkspaceInvitation( - id=model.id, - workspace_id=model.workspace_id, - inviter_user_id=model.inviter_user_id, - invitee_email=model.invitee_email, - role=model.role, - invitation_token=model.invitation_token, - status=model.status, - expires_at=model.expires_at, - accepted_at=model.accepted_at, - created_at=model.created_at, - ) diff --git a/packages/adapters/sqlalchemy_impl/workspace_repository.py b/packages/adapters/sqlalchemy_impl/workspace_repository.py deleted file mode 100644 index 2903a699e..000000000 --- a/packages/adapters/sqlalchemy_impl/workspace_repository.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -from sqlalchemy.orm import Session - -from packages.adapters.sqlalchemy_impl.models import WorkspaceModel -from packages.domain.entities import Workspace -from packages.ports.workspace_repository import WorkspaceRepository - - -class SQLAlchemyWorkspaceRepository(WorkspaceRepository): - def __init__(self, session: Session): - self.session = session - - def save(self, workspace: Workspace) -> None: - model = self.session.get(WorkspaceModel, workspace.id) - if model is None: - model = WorkspaceModel(id=workspace.id) - self.session.add(model) - - model.name = workspace.name - model.owner_user_id = workspace.owner_user_id - model.subscription_plan = workspace.subscription_plan - model.subscription_status = workspace.subscription_status - model.subscription_expires_at = workspace.subscription_expires_at - model.max_projects = workspace.max_projects - model.max_storage_gb = workspace.max_storage_gb - model.used_storage_gb = workspace.used_storage_gb - model.created_at = workspace.created_at - - self.session.commit() - self.session.refresh(model) - - def find_by_id(self, workspace_id: str) -> Workspace | None: - return self._to_entity(self.session.get(WorkspaceModel, workspace_id)) - - def delete(self, workspace_id: str) -> bool: - model = self.session.get(WorkspaceModel, workspace_id) - if model is None: - return False - self.session.delete(model) - self.session.commit() - return True - - @staticmethod - def _to_entity(model: WorkspaceModel | None) -> Workspace | None: - if model is None: - return None - return Workspace( - id=model.id, - name=model.name, - owner_user_id=model.owner_user_id, - subscription_plan=model.subscription_plan, - subscription_status=model.subscription_status, - subscription_expires_at=model.subscription_expires_at, - max_projects=int(model.max_projects), - max_storage_gb=int(model.max_storage_gb), - used_storage_gb=float(model.used_storage_gb), - created_at=model.created_at, - ) diff --git a/packages/application/asset_libraries.py b/packages/application/asset_libraries.py index b18ff3f09..105b8a267 100644 --- a/packages/application/asset_libraries.py +++ b/packages/application/asset_libraries.py @@ -8,7 +8,6 @@ from packages.ports.asset_library_repository import AssetLibraryRepository @dataclass(slots=True) class CreateAssetLibraryCommand: - workspace_id: str project_id: str name: str kind: AssetLibraryKind @@ -18,10 +17,10 @@ class ListAssetLibrariesUseCase: def __init__(self, asset_library_repository: AssetLibraryRepository): self.asset_library_repository = asset_library_repository - def execute(self, project_id: str, kind: AssetLibraryKind | None = None) -> list[AssetLibrary]: + def execute(self, project_id: str) -> list[AssetLibrary]: if not project_id.strip(): raise ValueError("project_id 不能为空") - return self.asset_library_repository.list_by_project(project_id.strip(), kind=kind) + return self.asset_library_repository.find_by_project(project_id.strip()) class CreateAssetLibraryUseCase: @@ -30,7 +29,6 @@ class CreateAssetLibraryUseCase: def execute(self, command: CreateAssetLibraryCommand) -> AssetLibrary: library = AssetLibrary.create( - workspace_id=command.workspace_id, project_id=command.project_id, name=command.name, kind=command.kind, diff --git a/packages/application/assets.py b/packages/application/assets.py index bc4813124..d38eceadb 100644 --- a/packages/application/assets.py +++ b/packages/application/assets.py @@ -8,7 +8,6 @@ from packages.ports.asset_repository import AssetRepository @dataclass(slots=True) class CreateAssetCommand: - workspace_id: str project_id: str library_id: str name: str @@ -35,7 +34,7 @@ class ListAssetsUseCase: def execute(self, library_id: str) -> list[Asset]: if not library_id.strip(): raise ValueError("library_id 不能为空") - return self.asset_repository.list_by_library(library_id.strip()) + return self.asset_repository.find_by_library(library_id.strip()) class CreateAssetUseCase: @@ -44,7 +43,6 @@ class CreateAssetUseCase: def execute(self, command: CreateAssetCommand) -> Asset: asset = Asset.create( - workspace_id=command.workspace_id, project_id=command.project_id, library_id=command.library_id, name=command.name, diff --git a/packages/application/classification_jobs.py b/packages/application/classification_jobs.py index 2d0db6edc..e4f682d22 100644 --- a/packages/application/classification_jobs.py +++ b/packages/application/classification_jobs.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from uuid import uuid4 from packages.domain import ClassificationJob from packages.ports.classification_job_repository import ClassificationJobRepository @@ -8,7 +9,6 @@ from packages.ports.classification_job_repository import ClassificationJobReposi @dataclass(slots=True) class SubmitClassificationJobCommand: - workspace_id: str project_id: str asset_id: str @@ -18,9 +18,13 @@ class SubmitClassificationJobUseCase: self.classification_job_repository = classification_job_repository def execute(self, command: SubmitClassificationJobCommand) -> ClassificationJob: - job = ClassificationJob.create( - workspace_id=command.workspace_id, + job = ClassificationJob( + id=uuid4().hex, project_id=command.project_id, asset_id=command.asset_id, + status="pending", + classification="", + confidence=0.0, + error_message="", ) return self.classification_job_repository.create(job) diff --git a/packages/application/generation_tasks.py b/packages/application/generation_tasks.py index 7ff8e3c7f..4dab04c7a 100644 --- a/packages/application/generation_tasks.py +++ b/packages/application/generation_tasks.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from uuid import uuid4 from packages.domain import GenerationTask from packages.ports.generation_task_repository import GenerationTaskRepository @@ -8,7 +9,6 @@ from packages.ports.generation_task_repository import GenerationTaskRepository @dataclass(slots=True) class CreateGenerationTaskCommand: - workspace_id: str project_id: str asset_library_id: str strategy_id: str = "" @@ -22,13 +22,19 @@ class CreateGenerationTaskUseCase: self.generation_task_repository = generation_task_repository def execute(self, command: CreateGenerationTaskCommand) -> GenerationTask: - task = GenerationTask.create( - workspace_id=command.workspace_id, + task = GenerationTask( + id=uuid4().hex, project_id=command.project_id, asset_library_id=command.asset_library_id, strategy_id=command.strategy_id, voice_library_id=command.voice_library_id, edit_plan_id=command.edit_plan_id, + status="pending", + progress=0.0, + result_count=0, + error_message="", + started_at=None, + completed_at=None, created_by_user_id=command.created_by_user_id, ) return self.generation_task_repository.create(task) diff --git a/packages/application/ingest_jobs.py b/packages/application/ingest_jobs.py index 00d5620ba..bbb05553e 100644 --- a/packages/application/ingest_jobs.py +++ b/packages/application/ingest_jobs.py @@ -8,7 +8,6 @@ from packages.ports.ingest_job_repository import IngestJobRepository @dataclass(slots=True) class SubmitIngestJobCommand: - workspace_id: str project_id: str library_id: str storage_key: str @@ -20,7 +19,6 @@ class SubmitIngestJobUseCase: def execute(self, command: SubmitIngestJobCommand) -> IngestJob: job = IngestJob.create( - workspace_id=command.workspace_id, project_id=command.project_id, library_id=command.library_id, storage_key=command.storage_key, diff --git a/packages/application/projects.py b/packages/application/projects.py index f9ac955b7..842c97eb7 100644 --- a/packages/application/projects.py +++ b/packages/application/projects.py @@ -8,7 +8,6 @@ from packages.ports import ProjectRepository @dataclass(slots=True) class CreateProjectCommand: - workspace_id: str name: str description: str = "" @@ -17,10 +16,10 @@ class ListProjectsUseCase: def __init__(self, project_repository: ProjectRepository): self.project_repository = project_repository - def execute(self, workspace_id: str) -> list[Project]: - if not workspace_id.strip(): - raise ValueError("workspace_id 不能为空") - return self.project_repository.list_by_workspace(workspace_id.strip()) + def execute(self, user_id: str) -> list[Project]: + if not user_id.strip(): + raise ValueError("user_id 不能为空") + return self.project_repository.find_accessible_projects(user_id.strip()) class GetProjectUseCase: @@ -37,10 +36,42 @@ class CreateProjectUseCase: def __init__(self, project_repository: ProjectRepository): self.project_repository = project_repository - def execute(self, command: CreateProjectCommand) -> Project: + def execute(self, command: CreateProjectCommand, owner_user_id: str) -> Project: project = Project.create( - workspace_id=command.workspace_id, + owner_user_id=owner_user_id, name=command.name, description=command.description, ) - return self.project_repository.create(project) + return self.project_repository.save(project) + + +class ShareProjectUseCase: + def __init__(self, project_repository: ProjectRepository): + self.project_repository = project_repository + + def execute(self, project_id: str, owner_user_id: str, shared_user_id: str) -> Project: + project = self.project_repository.find_by_id(project_id) + if not project: + raise ValueError("项目不存在") + if not project.is_owner(owner_user_id): + raise ValueError("只有项目所有者可以共享项目") + if shared_user_id not in project.shared_users: + project.shared_users.append(shared_user_id) + self.project_repository.save(project) + return project + + +class UnshareProjectUseCase: + def __init__(self, project_repository: ProjectRepository): + self.project_repository = project_repository + + def execute(self, project_id: str, owner_user_id: str, shared_user_id: str) -> Project: + project = self.project_repository.find_by_id(project_id) + if not project: + raise ValueError("项目不存在") + if not project.is_owner(owner_user_id): + raise ValueError("只有项目所有者可以取消共享") + if shared_user_id in project.shared_users: + project.shared_users.remove(shared_user_id) + self.project_repository.save(project) + return project diff --git a/packages/application/workspace/__init__.py b/packages/application/workspace/__init__.py deleted file mode 100644 index 165ad98bd..000000000 --- a/packages/application/workspace/__init__.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Workspace 相关 Use Cases""" - -from packages.application.workspace.accept_invitation_use_case import ( - AcceptInvitationRequest, - AcceptInvitationResponse, - AcceptInvitationUseCase, - DeclineInvitationRequest, - DeclineInvitationUseCase, -) -from packages.application.workspace.create_workspace_use_case import ( - CreateWorkspaceRequest, - CreateWorkspaceResponse, - CreateWorkspaceUseCase, -) -from packages.application.workspace.invite_member_use_case import ( - InviteMemberRequest, - InviteMemberResponse, - InviteMemberUseCase, -) -from packages.application.workspace.list_members_use_case import ( - ListMembersRequest, - ListMembersResponse, - ListMembersUseCase, - MemberInfo, -) -from packages.application.workspace.list_workspaces_use_case import ( - GetWorkspaceDetailRequest, - GetWorkspaceDetailUseCase, - ListWorkspacesRequest, - ListWorkspacesResponse, - ListWorkspacesUseCase, - WorkspaceDetailInfo, - WorkspaceInfo, -) -from packages.application.workspace.remove_member_use_case import ( - LeaveWorkspaceRequest, - LeaveWorkspaceUseCase, - RemoveMemberRequest, - RemoveMemberUseCase, -) -from packages.application.workspace.subscription_use_case import ( - CancelSubscriptionRequest, - CancelSubscriptionUseCase, - UpgradeSubscriptionRequest, - UpgradeSubscriptionResponse, - UpgradeSubscriptionUseCase, -) -from packages.application.workspace.update_member_role_use_case import ( - UpdateMemberRoleRequest, - UpdateMemberRoleResponse, - UpdateMemberRoleUseCase, -) - -__all__ = [ - "CreateWorkspaceUseCase", - "CreateWorkspaceRequest", - "CreateWorkspaceResponse", - "InviteMemberUseCase", - "InviteMemberRequest", - "InviteMemberResponse", - "AcceptInvitationUseCase", - "AcceptInvitationRequest", - "AcceptInvitationResponse", - "DeclineInvitationUseCase", - "DeclineInvitationRequest", - "RemoveMemberUseCase", - "RemoveMemberRequest", - "LeaveWorkspaceUseCase", - "LeaveWorkspaceRequest", - "UpdateMemberRoleUseCase", - "UpdateMemberRoleRequest", - "UpdateMemberRoleResponse", - "ListWorkspacesUseCase", - "ListWorkspacesRequest", - "ListWorkspacesResponse", - "GetWorkspaceDetailUseCase", - "GetWorkspaceDetailRequest", - "WorkspaceInfo", - "WorkspaceDetailInfo", - "ListMembersUseCase", - "ListMembersRequest", - "ListMembersResponse", - "MemberInfo", - "UpgradeSubscriptionUseCase", - "UpgradeSubscriptionRequest", - "UpgradeSubscriptionResponse", - "CancelSubscriptionUseCase", - "CancelSubscriptionRequest", -] diff --git a/packages/application/workspace/accept_invitation_use_case.py b/packages/application/workspace/accept_invitation_use_case.py deleted file mode 100644 index b2ab9ad3a..000000000 --- a/packages/application/workspace/accept_invitation_use_case.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -接受/拒绝邀请 Use Case -""" - -from datetime import datetime, timezone -from typing import Optional -from uuid import uuid4 - -from packages.domain.entities import InvitationStatus, WorkspaceMember - - -class AcceptInvitationRequest: - """接受邀请请求""" - - def __init__(self, invitation_token: str, user_id: str): - self.invitation_token = invitation_token - self.user_id = user_id - - -class AcceptInvitationResponse: - """接受邀请响应""" - - def __init__( - self, - workspace_id: str, - workspace_name: str, - role: str, - ): - self.workspace_id = workspace_id - self.workspace_name = workspace_name - self.role = role - - -class AcceptInvitationUseCase: - """接受邀请用例""" - - def __init__( - self, - workspace_repository, - workspace_member_repository, - workspace_invitation_repository, - user_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - self.workspace_invitation_repository = workspace_invitation_repository - self.user_repository = user_repository - - def execute(self, request: AcceptInvitationRequest) -> tuple[Optional[AcceptInvitationResponse], Optional[str]]: - """ - 执行接受邀请 - - Args: - request: 接受请求 - - Returns: - (响应, 错误信息) - """ - try: - # 1. 验证输入 - if not request.invitation_token: - return None, "Invitation token is required" - - if not request.user_id: - return None, "User ID is required" - - # 2. 查找邀请 - invitation = self.workspace_invitation_repository.find_by_token(request.invitation_token) - if not invitation: - return None, "Invalid invitation token" - - # 3. 检查邀请状态 - if invitation.status != InvitationStatus.PENDING: - return None, f"Invitation has already been {invitation.status}" - - # 4. 检查是否过期 - expires_at = invitation.expires_at - if expires_at and expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=timezone.utc) - if expires_at and datetime.now(timezone.utc) > expires_at: - # 更新状态为过期 - invitation.status = InvitationStatus.EXPIRED - self.workspace_invitation_repository.save(invitation) - return None, "Invitation has expired" - - # 5. 验证用户存在 - user = self.user_repository.find_by_id(request.user_id) - if not user: - return None, "User not found" - - # 6. 验证用户邮箱匹配 - if user.email.lower() != invitation.invitee_email.lower(): - return None, "This invitation is for a different email address" - - # 7. 验证 Workspace 存在 - workspace = self.workspace_repository.find_by_id(invitation.workspace_id) - if not workspace: - return None, "Workspace not found" - - # 8. 检查用户是否已经是成员 - existing_member = self.workspace_member_repository.find_by_workspace_and_user( - invitation.workspace_id, - request.user_id, - ) - if existing_member: - # 已经是成员,标记邀请为已接受 - invitation.status = InvitationStatus.ACCEPTED - invitation.accepted_at = datetime.now(timezone.utc) - self.workspace_invitation_repository.save(invitation) - - return ( - AcceptInvitationResponse( - workspace_id=workspace.id, - workspace_name=workspace.name, - role=existing_member.role, - ), - None, - ) - - # 9. 创建成员记录 - member = WorkspaceMember( - id=uuid4().hex, - workspace_id=invitation.workspace_id, - user_id=request.user_id, - role=invitation.role, - invited_by=invitation.inviter_user_id, - joined_at=datetime.now(timezone.utc), - ) - - self.workspace_member_repository.save(member) - - # 10. 更新邀请状态 - invitation.status = InvitationStatus.ACCEPTED - invitation.accepted_at = datetime.now(timezone.utc) - self.workspace_invitation_repository.save(invitation) - - # 11. 返回响应 - return ( - AcceptInvitationResponse( - workspace_id=workspace.id, - workspace_name=workspace.name, - role=member.role, - ), - None, - ) - - except Exception as e: - return None, f"Failed to accept invitation: {str(e)}" - - -class DeclineInvitationRequest: - """拒绝邀请请求""" - - def __init__(self, invitation_token: str): - self.invitation_token = invitation_token - - -class DeclineInvitationUseCase: - """拒绝邀请用例""" - - def __init__(self, workspace_invitation_repository): - self.workspace_invitation_repository = workspace_invitation_repository - - def execute(self, request: DeclineInvitationRequest) -> tuple[bool, Optional[str]]: - """ - 执行拒绝邀请 - - Args: - request: 拒绝请求 - - Returns: - (是否成功, 错误信息) - """ - try: - # 1. 验证输入 - if not request.invitation_token: - return False, "Invitation token is required" - - # 2. 查找邀请 - invitation = self.workspace_invitation_repository.find_by_token(request.invitation_token) - if not invitation: - return False, "Invalid invitation token" - - # 3. 检查邀请状态 - if invitation.status != InvitationStatus.PENDING: - return False, f"Invitation has already been {invitation.status}" - - # 4. 更新状态为已拒绝 - invitation.status = InvitationStatus.DECLINED - self.workspace_invitation_repository.save(invitation) - - return True, None - - except Exception as e: - return False, f"Failed to decline invitation: {str(e)}" diff --git a/packages/application/workspace/create_workspace_use_case.py b/packages/application/workspace/create_workspace_use_case.py deleted file mode 100644 index 1a87fca8e..000000000 --- a/packages/application/workspace/create_workspace_use_case.py +++ /dev/null @@ -1,138 +0,0 @@ -""" -创建 Workspace Use Case -""" - -from datetime import datetime, timezone -from typing import Optional -from uuid import uuid4 - -from packages.domain.entities import Workspace, WorkspaceMember, WorkspaceMemberRole - - -class CreateWorkspaceRequest: - """创建工作空间请求""" - - def __init__( - self, - name: str, - owner_user_id: str, - subscription_plan: str = "free", - ): - self.name = name.strip() - self.owner_user_id = owner_user_id - self.subscription_plan = subscription_plan - - -class CreateWorkspaceResponse: - """创建工作空间响应""" - - def __init__( - self, - workspace_id: str, - name: str, - subscription_plan: str, - max_projects: int, - max_storage_gb: int, - ): - self.workspace_id = workspace_id - self.name = name - self.subscription_plan = subscription_plan - self.max_projects = max_projects - self.max_storage_gb = max_storage_gb - - -class CreateWorkspaceUseCase: - """创建工作空间用例""" - - # 订阅计划配额配置 - PLAN_QUOTAS = { - "free": {"max_projects": 3, "max_storage_gb": 10}, - "pro": {"max_projects": 999999, "max_storage_gb": 100}, # 999999 表示无限 - "enterprise": {"max_projects": 999999, "max_storage_gb": 1000}, - } - - def __init__( - self, - workspace_repository, - workspace_member_repository, - user_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - self.user_repository = user_repository - - def execute(self, request: CreateWorkspaceRequest) -> tuple[Optional[CreateWorkspaceResponse], Optional[str]]: - """ - 执行创建工作空间 - - Args: - request: 创建请求 - - Returns: - (响应, 错误信息) - """ - try: - # 1. 验证输入 - if not request.name: - return None, "Workspace name is required" - - if len(request.name) > 100: - return None, "Workspace name is too long (max 100 characters)" - - if not request.owner_user_id: - return None, "Owner user ID is required" - - # 2. 验证用户存在 - owner = self.user_repository.find_by_id(request.owner_user_id) - if not owner: - return None, "Owner user not found" - - # 3. 验证订阅计划 - if request.subscription_plan not in self.PLAN_QUOTAS: - return None, f"Invalid subscription plan: {request.subscription_plan}" - - # 4. 获取配额 - quota = self.PLAN_QUOTAS[request.subscription_plan] - - # 5. 创建 Workspace - workspace = Workspace( - id=uuid4().hex, - name=request.name, - owner_user_id=request.owner_user_id, - subscription_plan=request.subscription_plan, - subscription_status="active", - max_projects=quota["max_projects"], - max_storage_gb=quota["max_storage_gb"], - used_storage_gb=0.0, - created_at=datetime.now(timezone.utc), - ) - - # 6. 保存 Workspace - self.workspace_repository.save(workspace) - - # 7. 创建 Owner 成员记录 - owner_member = WorkspaceMember( - id=uuid4().hex, - workspace_id=workspace.id, - user_id=request.owner_user_id, - role=WorkspaceMemberRole.OWNER, - invited_by=None, # Owner 不需要邀请 - joined_at=datetime.now(timezone.utc), - ) - - self.workspace_member_repository.save(owner_member) - - # 8. 返回响应 - return ( - CreateWorkspaceResponse( - workspace_id=workspace.id, - name=workspace.name, - subscription_plan=workspace.subscription_plan, - max_projects=workspace.max_projects, - max_storage_gb=workspace.max_storage_gb, - ), - None, - ) - - except Exception as e: - return None, f"Failed to create workspace: {str(e)}" diff --git a/packages/application/workspace/invite_member_use_case.py b/packages/application/workspace/invite_member_use_case.py deleted file mode 100644 index d907cedc2..000000000 --- a/packages/application/workspace/invite_member_use_case.py +++ /dev/null @@ -1,194 +0,0 @@ -""" -邀请成员到 Workspace Use Case -""" - -import secrets -from datetime import datetime, timedelta, timezone -from typing import Optional -from uuid import uuid4 - -from packages.adapters.smtp import get_email_service -from packages.domain.entities import ( - InvitationStatus, - WorkspaceInvitation, - WorkspaceMemberRole, -) - - -class InviteMemberRequest: - """邀请成员请求""" - - def __init__( - self, - workspace_id: str, - inviter_user_id: str, - invitee_email: str, - role: str, - ): - self.workspace_id = workspace_id - self.inviter_user_id = inviter_user_id - self.invitee_email = invitee_email.strip().lower() - self.role = role - - -class InviteMemberResponse: - """邀请成员响应""" - - def __init__( - self, - invitation_id: str, - invitee_email: str, - role: str, - expires_at: datetime, - ): - self.invitation_id = invitation_id - self.invitee_email = invitee_email - self.role = role - self.expires_at = expires_at - - -class InviteMemberUseCase: - """邀请成员用例""" - - VALID_ROLES = [ - WorkspaceMemberRole.ADMIN, - WorkspaceMemberRole.MEMBER, - WorkspaceMemberRole.VIEWER, - ] - - def __init__( - self, - workspace_repository, - workspace_member_repository, - workspace_invitation_repository, - user_repository, - base_url: str = "http://localhost:3000", - invitation_expire_days: int = 7, - email_service=None, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - self.workspace_invitation_repository = workspace_invitation_repository - self.user_repository = user_repository - self.base_url = base_url - self.invitation_expire_days = invitation_expire_days - self.email_service = email_service or get_email_service() - - def execute(self, request: InviteMemberRequest) -> tuple[Optional[InviteMemberResponse], Optional[str]]: - """ - 执行邀请成员 - - Args: - request: 邀请请求 - - Returns: - (响应, 错误信息) - """ - try: - # 1. 验证输入 - if not request.workspace_id: - return None, "Workspace ID is required" - - if not request.inviter_user_id: - return None, "Inviter user ID is required" - - if not request.invitee_email: - return None, "Invitee email is required" - - if not request.role: - return None, "Role is required" - - # 2. 验证角色(不能邀请 owner) - if request.role not in self.VALID_ROLES: - return None, f"Invalid role: {request.role}. Cannot invite as owner." - - # 3. 验证 Workspace 存在 - workspace = self.workspace_repository.find_by_id(request.workspace_id) - if not workspace: - return None, "Workspace not found" - - # 4. 验证邀请人是成员且有权限(owner 或 admin) - inviter_member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.inviter_user_id, - ) - if not inviter_member: - return None, "You are not a member of this workspace" - - if inviter_member.role not in [ - WorkspaceMemberRole.OWNER, - WorkspaceMemberRole.ADMIN, - ]: - return None, "Only owners and admins can invite members" - - # 5. 检查被邀请人是否已经是成员 - invitee_user = self.user_repository.find_by_email(request.invitee_email) - if invitee_user: - existing_member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - invitee_user.id, - ) - if existing_member: - return None, "User is already a member of this workspace" - - # 6. 检查是否已有待处理的邀请 - existing_invitation = self.workspace_invitation_repository.find_pending_by_workspace_and_email( - request.workspace_id, - request.invitee_email, - ) - if existing_invitation: - return None, "An invitation has already been sent to this email" - - # 7. 生成邀请令牌 - invitation_token = secrets.token_urlsafe(32) - expires_at = datetime.now(timezone.utc) + timedelta(days=self.invitation_expire_days) - - # 8. 创建邀请记录 - invitation = WorkspaceInvitation( - id=uuid4().hex, - workspace_id=request.workspace_id, - inviter_user_id=request.inviter_user_id, - invitee_email=request.invitee_email, - role=request.role, - invitation_token=invitation_token, - status=InvitationStatus.PENDING, - expires_at=expires_at, - created_at=datetime.now(timezone.utc), - ) - - # 9. 保存邀请 - self.workspace_invitation_repository.save(invitation) - - # 10. 发送邀请邮件 - invitation_url = f"{self.base_url}/invitations/{invitation_token}/accept" - - try: - inviter = self.user_repository.find_by_id(request.inviter_user_id) - inviter_name = inviter.display_name if inviter else "Someone" - - success, error = self.email_service.send_workspace_invitation_email( - to_email=request.invitee_email, - inviter_name=inviter_name, - workspace_name=workspace.name, - role=request.role, - invitation_url=invitation_url, - ) - - if not success: - print(f"Failed to send invitation email: {error}") - except Exception as e: - print(f"Email service error: {e}") - - # 11. 返回响应 - return ( - InviteMemberResponse( - invitation_id=invitation.id, - invitee_email=invitation.invitee_email, - role=invitation.role, - expires_at=invitation.expires_at, - ), - None, - ) - - except Exception as e: - return None, f"Failed to invite member: {str(e)}" diff --git a/packages/application/workspace/list_members_use_case.py b/packages/application/workspace/list_members_use_case.py deleted file mode 100644 index afe4a42c5..000000000 --- a/packages/application/workspace/list_members_use_case.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -获取成员列表 Use Case -""" - -from datetime import datetime -from typing import List, Optional - - -class MemberInfo: - """成员信息""" - - def __init__( - self, - member_id: str, - user_id: str, - username: str, - email: str, - display_name: str, - role: str, - invited_by: Optional[str], - joined_at: datetime, - ): - self.member_id = member_id - self.user_id = user_id - self.username = username - self.email = email - self.display_name = display_name - self.role = role - self.invited_by = invited_by - self.joined_at = joined_at - - -class ListMembersRequest: - """获取成员列表请求""" - - def __init__(self, workspace_id: str, requester_user_id: str): - self.workspace_id = workspace_id - self.requester_user_id = requester_user_id - - -class ListMembersResponse: - """获取成员列表响应""" - - def __init__(self, members: List[MemberInfo]): - self.members = members - - -class ListMembersUseCase: - """获取成员列表用例""" - - def __init__( - self, - workspace_repository, - workspace_member_repository, - user_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - self.user_repository = user_repository - - def execute(self, request: ListMembersRequest) -> tuple[Optional[ListMembersResponse], Optional[str]]: - """ - 执行获取成员列表 - - Args: - request: 请求 - - Returns: - (响应, 错误信息) - """ - try: - # 1. 验证输入 - if not request.workspace_id: - return None, "Workspace ID is required" - - if not request.requester_user_id: - return None, "Requester user ID is required" - - # 2. 验证工作空间存在 - workspace = self.workspace_repository.find_by_id(request.workspace_id) - if not workspace: - return None, "Workspace not found" - - # 3. 验证请求者是成员(只有成员才能查看成员列表) - requester_member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.requester_user_id, - ) - if not requester_member: - return None, "You are not a member of this workspace" - - # 4. 获取所有成员 - members = self.workspace_member_repository.find_by_workspace(request.workspace_id) - - # 5. 获取每个成员的用户信息 - member_infos = [] - for member in members: - user = self.user_repository.find_by_id(member.user_id) - if not user: - continue # 跳过不存在的用户 - - member_info = MemberInfo( - member_id=member.id, - user_id=user.id, - username=user.username, - email=user.email, - display_name=user.display_name, - role=member.role, - invited_by=member.invited_by, - joined_at=member.joined_at, - ) - member_infos.append(member_info) - - # 6. 返回响应 - return ListMembersResponse(members=member_infos), None - - except Exception as e: - return None, f"Failed to list members: {str(e)}" diff --git a/packages/application/workspace/list_workspaces_use_case.py b/packages/application/workspace/list_workspaces_use_case.py deleted file mode 100644 index 1c27e3841..000000000 --- a/packages/application/workspace/list_workspaces_use_case.py +++ /dev/null @@ -1,218 +0,0 @@ -""" -获取工作空间列表和详情 Use Case -""" - -from datetime import datetime -from typing import List, Optional - - -class WorkspaceInfo: - """工作空间信息""" - - def __init__( - self, - workspace_id: str, - name: str, - owner_user_id: str, - subscription_plan: str, - subscription_status: str, - max_projects: int, - max_storage_gb: int, - used_storage_gb: float, - member_count: int, - user_role: str, - created_at: datetime, - ): - self.workspace_id = workspace_id - self.name = name - self.owner_user_id = owner_user_id - self.subscription_plan = subscription_plan - self.subscription_status = subscription_status - self.max_projects = max_projects - self.max_storage_gb = max_storage_gb - self.used_storage_gb = used_storage_gb - self.member_count = member_count - self.user_role = user_role - self.created_at = created_at - - -class ListWorkspacesRequest: - """获取工作空间列表请求""" - - def __init__(self, user_id: str): - self.user_id = user_id - - -class ListWorkspacesResponse: - """获取工作空间列表响应""" - - def __init__(self, workspaces: List[WorkspaceInfo]): - self.workspaces = workspaces - - -class ListWorkspacesUseCase: - """获取工作空间列表用例""" - - def __init__( - self, - workspace_repository, - workspace_member_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - - def execute(self, request: ListWorkspacesRequest) -> tuple[Optional[ListWorkspacesResponse], Optional[str]]: - """ - 执行获取工作空间列表 - - Args: - request: 请求 - - Returns: - (响应, 错误信息) - """ - try: - # 1. 验证输入 - if not request.user_id: - return None, "User ID is required" - - # 2. 获取用户所有的成员记录 - memberships = self.workspace_member_repository.find_by_user(request.user_id) - - # 3. 获取每个工作空间的信息 - workspace_infos = [] - for membership in memberships: - workspace = self.workspace_repository.find_by_id(membership.workspace_id) - if not workspace: - continue - - # 获取成员数量 - member_count = self.workspace_member_repository.count_by_workspace(membership.workspace_id) - - workspace_info = WorkspaceInfo( - workspace_id=workspace.id, - name=workspace.name, - owner_user_id=workspace.owner_user_id, - subscription_plan=workspace.subscription_plan, - subscription_status=workspace.subscription_status, - max_projects=workspace.max_projects, - max_storage_gb=workspace.max_storage_gb, - used_storage_gb=workspace.used_storage_gb, - member_count=member_count, - user_role=membership.role, - created_at=workspace.created_at, - ) - workspace_infos.append(workspace_info) - - # 4. 返回响应 - return ListWorkspacesResponse(workspaces=workspace_infos), None - - except Exception as e: - return None, f"Failed to list workspaces: {str(e)}" - - -class GetWorkspaceDetailRequest: - """获取工作空间详情请求""" - - def __init__(self, workspace_id: str, user_id: str): - self.workspace_id = workspace_id - self.user_id = user_id - - -class WorkspaceDetailInfo: - """工作空间详情信息""" - - def __init__( - self, - workspace_id: str, - name: str, - owner_user_id: str, - subscription_plan: str, - subscription_status: str, - subscription_expires_at: Optional[datetime], - max_projects: int, - max_storage_gb: int, - used_storage_gb: float, - member_count: int, - user_role: str, - created_at: datetime, - ): - self.workspace_id = workspace_id - self.name = name - self.owner_user_id = owner_user_id - self.subscription_plan = subscription_plan - self.subscription_status = subscription_status - self.subscription_expires_at = subscription_expires_at - self.max_projects = max_projects - self.max_storage_gb = max_storage_gb - self.used_storage_gb = used_storage_gb - self.member_count = member_count - self.user_role = user_role - self.created_at = created_at - - -class GetWorkspaceDetailUseCase: - """获取工作空间详情用例""" - - def __init__( - self, - workspace_repository, - workspace_member_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - - def execute(self, request: GetWorkspaceDetailRequest) -> tuple[Optional[WorkspaceDetailInfo], Optional[str]]: - """ - 执行获取工作空间详情 - - Args: - request: 请求 - - Returns: - (详情信息, 错误信息) - """ - try: - # 1. 验证输入 - if not request.workspace_id: - return None, "Workspace ID is required" - - if not request.user_id: - return None, "User ID is required" - - # 2. 验证工作空间存在 - workspace = self.workspace_repository.find_by_id(request.workspace_id) - if not workspace: - return None, "Workspace not found" - - # 3. 验证用户是成员 - membership = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.user_id, - ) - if not membership: - return None, "You are not a member of this workspace" - - # 4. 获取成员数量 - member_count = self.workspace_member_repository.count_by_workspace(request.workspace_id) - - # 5. 构建详情信息 - detail_info = WorkspaceDetailInfo( - workspace_id=workspace.id, - name=workspace.name, - owner_user_id=workspace.owner_user_id, - subscription_plan=workspace.subscription_plan, - subscription_status=workspace.subscription_status, - subscription_expires_at=workspace.subscription_expires_at, - max_projects=workspace.max_projects, - max_storage_gb=workspace.max_storage_gb, - used_storage_gb=workspace.used_storage_gb, - member_count=member_count, - user_role=membership.role, - created_at=workspace.created_at, - ) - - return detail_info, None - - except Exception as e: - return None, f"Failed to get workspace detail: {str(e)}" diff --git a/packages/application/workspace/remove_member_use_case.py b/packages/application/workspace/remove_member_use_case.py deleted file mode 100644 index 046cf5574..000000000 --- a/packages/application/workspace/remove_member_use_case.py +++ /dev/null @@ -1,171 +0,0 @@ -""" -移除成员 Use Case -""" - -from typing import Optional - -from packages.domain.entities import WorkspaceMemberRole - - -class RemoveMemberRequest: - """移除成员请求""" - - def __init__( - self, - workspace_id: str, - requester_user_id: str, - target_user_id: str, - ): - self.workspace_id = workspace_id - self.requester_user_id = requester_user_id - self.target_user_id = target_user_id - - -class RemoveMemberUseCase: - """移除成员用例""" - - def __init__( - self, - workspace_repository, - workspace_member_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - - def execute(self, request: RemoveMemberRequest) -> tuple[bool, Optional[str]]: - """ - 执行移除成员 - - Args: - request: 移除请求 - - Returns: - (是否成功, 错误信息) - """ - try: - # 1. 验证输入 - if not request.workspace_id: - return False, "Workspace ID is required" - - if not request.requester_user_id: - return False, "Requester user ID is required" - - if not request.target_user_id: - return False, "Target user ID is required" - - # 2. 验证 Workspace 存在 - workspace = self.workspace_repository.find_by_id(request.workspace_id) - if not workspace: - return False, "Workspace not found" - - # 3. 验证请求者是成员且有权限 - requester_member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.requester_user_id, - ) - if not requester_member: - return False, "You are not a member of this workspace" - - if requester_member.role not in [ - WorkspaceMemberRole.OWNER, - WorkspaceMemberRole.ADMIN, - ]: - return False, "Only owners and admins can remove members" - - # 4. 验证目标成员存在 - target_member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.target_user_id, - ) - if not target_member: - return False, "Target user is not a member of this workspace" - - # 5. 不能移除自己(应该用离开 workspace 的功能) - if request.requester_user_id == request.target_user_id: - return False, "Cannot remove yourself. Use leave workspace instead." - - # 6. 不能移除 owner - if target_member.role == WorkspaceMemberRole.OWNER: - return False, "Cannot remove the workspace owner" - - # 7. Admin 不能移除另一个 Admin(只有 owner 可以) - if requester_member.role == WorkspaceMemberRole.ADMIN and target_member.role == WorkspaceMemberRole.ADMIN: - return False, "Admins cannot remove other admins" - - # 8. 删除成员记录 - success = self.workspace_member_repository.delete(target_member.id) - if not success: - return False, "Failed to remove member" - - return True, None - - except Exception as e: - return False, f"Failed to remove member: {str(e)}" - - -class LeaveWorkspaceRequest: - """离开 Workspace 请求""" - - def __init__(self, workspace_id: str, user_id: str): - self.workspace_id = workspace_id - self.user_id = user_id - - -class LeaveWorkspaceUseCase: - """离开 Workspace 用例""" - - def __init__( - self, - workspace_repository, - workspace_member_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - - def execute(self, request: LeaveWorkspaceRequest) -> tuple[bool, Optional[str]]: - """ - 执行离开 Workspace - - Args: - request: 离开请求 - - Returns: - (是否成功, 错误信息) - """ - try: - # 1. 验证输入 - if not request.workspace_id: - return False, "Workspace ID is required" - - if not request.user_id: - return False, "User ID is required" - - # 2. 验证 Workspace 存在 - workspace = self.workspace_repository.find_by_id(request.workspace_id) - if not workspace: - return False, "Workspace not found" - - # 3. 验证用户是成员 - member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.user_id, - ) - if not member: - return False, "You are not a member of this workspace" - - # 4. Owner 不能离开(需要先转移 ownership 或删除 workspace) - if member.role == WorkspaceMemberRole.OWNER: - return ( - False, - "Owner cannot leave workspace. Transfer ownership or delete workspace first.", - ) - - # 5. 删除成员记录 - success = self.workspace_member_repository.delete(member.id) - if not success: - return False, "Failed to leave workspace" - - return True, None - - except Exception as e: - return False, f"Failed to leave workspace: {str(e)}" diff --git a/packages/application/workspace/subscription_use_case.py b/packages/application/workspace/subscription_use_case.py deleted file mode 100644 index dd2952edd..000000000 --- a/packages/application/workspace/subscription_use_case.py +++ /dev/null @@ -1,223 +0,0 @@ -""" -Subscription 管理 Use Case -""" - -from datetime import datetime, timedelta, timezone -from typing import Optional - -from packages.domain.entities import WorkspaceMemberRole - - -class UpgradeSubscriptionRequest: - """升级订阅请求""" - - def __init__( - self, - workspace_id: str, - requester_user_id: str, - new_plan: str, - ): - self.workspace_id = workspace_id - self.requester_user_id = requester_user_id - self.new_plan = new_plan - - -class UpgradeSubscriptionResponse: - """升级订阅响应""" - - def __init__( - self, - workspace_id: str, - old_plan: str, - new_plan: str, - max_projects: int, - max_storage_gb: int, - ): - self.workspace_id = workspace_id - self.old_plan = old_plan - self.new_plan = new_plan - self.max_projects = max_projects - self.max_storage_gb = max_storage_gb - - -class UpgradeSubscriptionUseCase: - """升级订阅用例""" - - # 订阅计划配额 - PLAN_QUOTAS = { - "free": {"max_projects": 3, "max_storage_gb": 10, "price": 0}, - "pro": {"max_projects": 999999, "max_storage_gb": 100, "price": 99}, - "enterprise": {"max_projects": 999999, "max_storage_gb": 1000, "price": 999}, - } - - # 计划等级 - PLAN_LEVELS = { - "free": 0, - "pro": 1, - "enterprise": 2, - } - - def __init__( - self, - workspace_repository, - workspace_member_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - - def execute( - self, request: UpgradeSubscriptionRequest - ) -> tuple[Optional[UpgradeSubscriptionResponse], Optional[str]]: - """ - 执行升级订阅 - - Args: - request: 升级请求 - - Returns: - (响应, 错误信息) - """ - try: - # 1. 验证输入 - if not request.workspace_id: - return None, "Workspace ID is required" - - if not request.requester_user_id: - return None, "Requester user ID is required" - - if not request.new_plan: - return None, "New plan is required" - - # 2. 验证新计划有效 - if request.new_plan not in self.PLAN_QUOTAS: - return None, f"Invalid plan: {request.new_plan}" - - # 3. 验证工作空间存在 - workspace = self.workspace_repository.find_by_id(request.workspace_id) - if not workspace: - return None, "Workspace not found" - - # 4. 验证权限(只有 Owner 可以管理订阅) - member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.requester_user_id, - ) - if not member: - return None, "You are not a member of this workspace" - - if member.role != WorkspaceMemberRole.OWNER: - return None, "Only workspace owner can manage subscription" - - # 5. 检查是否是升级(不能降级到免费计划,需要用取消订阅) - current_level = self.PLAN_LEVELS.get(workspace.subscription_plan, 0) - new_level = self.PLAN_LEVELS.get(request.new_plan, 0) - - if new_level < current_level: - return ( - None, - "Cannot downgrade plan. Use cancel subscription to return to free plan.", - ) - - if new_level == current_level: - return None, f"Workspace is already on {request.new_plan} plan" - - # 6. 更新订阅 - old_plan = workspace.subscription_plan - quota = self.PLAN_QUOTAS[request.new_plan] - - workspace.subscription_plan = request.new_plan - workspace.subscription_status = "active" - workspace.max_projects = quota["max_projects"] - workspace.max_storage_gb = quota["max_storage_gb"] - - # 设置过期时间(假设按月订阅) - workspace.subscription_expires_at = datetime.now(timezone.utc) + timedelta(days=30) - - self.workspace_repository.save(workspace) - - # 7. 返回响应 - return ( - UpgradeSubscriptionResponse( - workspace_id=workspace.id, - old_plan=old_plan, - new_plan=workspace.subscription_plan, - max_projects=workspace.max_projects, - max_storage_gb=workspace.max_storage_gb, - ), - None, - ) - - except Exception as e: - return None, f"Failed to upgrade subscription: {str(e)}" - - -class CancelSubscriptionRequest: - """取消订阅请求""" - - def __init__(self, workspace_id: str, requester_user_id: str): - self.workspace_id = workspace_id - self.requester_user_id = requester_user_id - - -class CancelSubscriptionUseCase: - """取消订阅用例""" - - def __init__( - self, - workspace_repository, - workspace_member_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - - def execute(self, request: CancelSubscriptionRequest) -> tuple[bool, Optional[str]]: - """ - 执行取消订阅 - - Args: - request: 取消请求 - - Returns: - (是否成功, 错误信息) - """ - try: - # 1. 验证输入 - if not request.workspace_id: - return False, "Workspace ID is required" - - if not request.requester_user_id: - return False, "Requester user ID is required" - - # 2. 验证工作空间存在 - workspace = self.workspace_repository.find_by_id(request.workspace_id) - if not workspace: - return False, "Workspace not found" - - # 3. 验证权限(只有 Owner 可以管理订阅) - member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.requester_user_id, - ) - if not member: - return False, "You are not a member of this workspace" - - if member.role != WorkspaceMemberRole.OWNER: - return False, "Only workspace owner can manage subscription" - - # 4. 检查当前计划 - if workspace.subscription_plan == "free": - return False, "Workspace is already on free plan" - - # 5. 降级到 free 计划 - workspace.subscription_plan = "free" - workspace.subscription_status = "active" - workspace.subscription_expires_at = None - workspace.max_projects = 3 - workspace.max_storage_gb = 10 - - self.workspace_repository.save(workspace) - - return True, None - - except Exception as e: - return False, f"Failed to cancel subscription: {str(e)}" diff --git a/packages/application/workspace/update_member_role_use_case.py b/packages/application/workspace/update_member_role_use_case.py deleted file mode 100644 index f11b0f208..000000000 --- a/packages/application/workspace/update_member_role_use_case.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -修改成员角色 Use Case -""" - -from typing import Optional - -from packages.domain.entities import WorkspaceMemberRole - - -class UpdateMemberRoleRequest: - """修改成员角色请求""" - - def __init__( - self, - workspace_id: str, - requester_user_id: str, - target_user_id: str, - new_role: str, - ): - self.workspace_id = workspace_id - self.requester_user_id = requester_user_id - self.target_user_id = target_user_id - self.new_role = new_role - - -class UpdateMemberRoleResponse: - """修改成员角色响应""" - - def __init__(self, user_id: str, old_role: str, new_role: str): - self.user_id = user_id - self.old_role = old_role - self.new_role = new_role - - -class UpdateMemberRoleUseCase: - """修改成员角色用例""" - - VALID_ROLES = [ - WorkspaceMemberRole.ADMIN, - WorkspaceMemberRole.MEMBER, - WorkspaceMemberRole.VIEWER, - ] - - def __init__( - self, - workspace_repository, - workspace_member_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - - def execute(self, request: UpdateMemberRoleRequest) -> tuple[Optional[UpdateMemberRoleResponse], Optional[str]]: - """ - 执行修改成员角色 - - Args: - request: 修改请求 - - Returns: - (响应, 错误信息) - """ - try: - # 1. 验证输入 - if not request.workspace_id: - return None, "Workspace ID is required" - - if not request.requester_user_id: - return None, "Requester user ID is required" - - if not request.target_user_id: - return None, "Target user ID is required" - - if not request.new_role: - return None, "New role is required" - - # 2. 验证新角色(不能修改为 owner) - if request.new_role not in self.VALID_ROLES: - return ( - None, - f"Invalid role: {request.new_role}. Cannot change to owner.", - ) - - # 3. 验证 Workspace 存在 - workspace = self.workspace_repository.find_by_id(request.workspace_id) - if not workspace: - return None, "Workspace not found" - - # 4. 验证请求者是成员且有权限(只有 owner 和 admin 可以修改角色) - requester_member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.requester_user_id, - ) - if not requester_member: - return None, "You are not a member of this workspace" - - if requester_member.role not in [ - WorkspaceMemberRole.OWNER, - WorkspaceMemberRole.ADMIN, - ]: - return None, "Only owners and admins can change member roles" - - # 5. 验证目标成员存在 - target_member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.target_user_id, - ) - if not target_member: - return None, "Target user is not a member of this workspace" - - # 6. 不能修改自己的角色 - if request.requester_user_id == request.target_user_id: - return None, "Cannot change your own role" - - # 7. 不能修改 owner 的角色 - if target_member.role == WorkspaceMemberRole.OWNER: - return None, "Cannot change the owner's role" - - # 8. Admin 不能修改另一个 Admin 的角色(只有 owner 可以) - if requester_member.role == WorkspaceMemberRole.ADMIN and target_member.role == WorkspaceMemberRole.ADMIN: - return None, "Admins cannot change other admins' roles" - - # 9. 检查角色是否相同 - if target_member.role == request.new_role: - return None, f"User already has the {request.new_role} role" - - # 10. 更新角色 - old_role = target_member.role - target_member.role = request.new_role - self.workspace_member_repository.save(target_member) - - # 11. 返回响应 - return ( - UpdateMemberRoleResponse( - user_id=request.target_user_id, - old_role=old_role, - new_role=request.new_role, - ), - None, - ) - - except Exception as e: - return None, f"Failed to update member role: {str(e)}" diff --git a/packages/domain/entities.py b/packages/domain/entities.py index 7d3ed3088..4de800777 100644 --- a/packages/domain/entities.py +++ b/packages/domain/entities.py @@ -34,96 +34,55 @@ class User: password_reset_expires_at: datetime | None = None last_login_at: datetime | None = None last_login_ip: str | None = None - created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - - -@dataclass(slots=True) -class Workspace: - id: str - name: str - owner_user_id: str - # 订阅相关字段 + # 订阅相关字段 (移到 User 级别) subscription_plan: str = "free" # free, pro, enterprise subscription_status: str = "active" # active, cancelled, expired subscription_expires_at: datetime | None = None - # 配额限制 + # 配额限制 (移到 User 级别) max_projects: int = 3 # free: 3, pro: unlimited, enterprise: unlimited max_storage_gb: int = 10 # free: 10, pro: 100, enterprise: 1000 used_storage_gb: float = 0.0 created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) -class WorkspaceMemberRole(StrEnum): - """工作空间成员角色""" - - OWNER = "owner" # 所有者(创建者,唯一) - ADMIN = "admin" # 管理员(可管理成员和项目) - MEMBER = "member" # 成员(可创建和编辑项目) - VIEWER = "viewer" # 查看者(只读) - - -class InvitationStatus(StrEnum): - """邀请状态""" - - PENDING = "pending" # 待处理 - ACCEPTED = "accepted" # 已接受 - DECLINED = "declined" # 已拒绝 - EXPIRED = "expired" # 已过期 - - -@dataclass(slots=True) -class WorkspaceMember: - """工作空间成员""" - - id: str - workspace_id: str - user_id: str - role: str # owner, admin, member, viewer - invited_by: str | None = None # 邀请人 user_id - joined_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - - -@dataclass(slots=True) -class WorkspaceInvitation: - """工作空间邀请""" - - id: str - workspace_id: str - inviter_user_id: str # 邀请人 - invitee_email: str # 被邀请人邮箱 - role: str # 邀请角色 - invitation_token: str # 邀请令牌 - status: str = "pending" # pending, accepted, declined, expired - expires_at: datetime | None = None # 过期时间 - accepted_at: datetime | None = None # 接受时间 - created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - - @dataclass(slots=True) class Project: id: str - workspace_id: str + owner_user_id: str name: str description: str = "" + shared_users: list[str] = field(default_factory=list) # 被共享的用户 ID 列表 created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) @classmethod - def create(cls, workspace_id: str, name: str, description: str = "") -> "Project": + def create(cls, owner_user_id: str, name: str, description: str = "") -> "Project": clean_name = name.strip() if not clean_name: raise ValueError("项目名称不能为空") return cls( id=uuid4().hex, - workspace_id=workspace_id, + owner_user_id=owner_user_id, name=clean_name, description=description.strip(), + shared_users=[], ) + def is_owner(self, user_id: str) -> bool: + """检查用户是否为项目所有者""" + return self.owner_user_id == user_id + + def is_shared_with(self, user_id: str) -> bool: + """检查项目是否共享给指定用户""" + return user_id in self.shared_users + + def can_access(self, user_id: str) -> bool: + """检查用户是否有权限访问项目""" + return self.is_owner(user_id) or self.is_shared_with(user_id) + @dataclass(slots=True) class AssetLibrary: id: str - workspace_id: str project_id: str name: str kind: AssetLibraryKind @@ -135,7 +94,6 @@ class AssetLibrary: @classmethod def create( cls, - workspace_id: str, project_id: str, name: str, kind: AssetLibraryKind, @@ -145,7 +103,6 @@ class AssetLibrary: raise ValueError("素材库名称不能为空") return cls( id=uuid4().hex, - workspace_id=workspace_id, project_id=project_id, name=clean_name, kind=kind, @@ -171,7 +128,6 @@ class ClassificationStatus(StrEnum): @dataclass(slots=True) class Asset: id: str - workspace_id: str project_id: str library_id: str name: str @@ -196,7 +152,6 @@ class Asset: @classmethod def create( cls, - workspace_id: str, project_id: str, library_id: str, name: str, @@ -225,7 +180,6 @@ class Asset: raise ValueError("mime_type 不能为空") return cls( id=uuid4().hex, - workspace_id=workspace_id, project_id=project_id, library_id=library_id, name=clean_name, @@ -266,7 +220,6 @@ class Asset: @dataclass(slots=True) class IngestJob: id: str - workspace_id: str project_id: str library_id: str storage_key: str @@ -279,13 +232,10 @@ class IngestJob: @classmethod def create( cls, - workspace_id: str, project_id: str, library_id: str, storage_key: str, ) -> "IngestJob": - if not workspace_id.strip(): - raise ValueError("workspace_id 不能为空") if not project_id.strip(): raise ValueError("project_id 不能为空") if not library_id.strip(): @@ -294,8 +244,149 @@ class IngestJob: raise ValueError("storage_key 不能为空") return cls( id=uuid4().hex, - workspace_id=workspace_id.strip(), project_id=project_id.strip(), library_id=library_id.strip(), storage_key=storage_key.strip(), ) + + +# 继续读取其他实体定义 - 生成任务、生成视频等 +@dataclass(slots=True) +class ClassificationJob: + id: str + project_id: str + asset_id: str + status: str = "pending" + classification: str = "" + confidence: float = 0.0 + error_message: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass(slots=True) +class GenerationTask: + id: str + project_id: str + asset_library_id: str + strategy_id: str = "" + voice_library_id: str = "" + edit_plan_id: str = "" + status: str = "pending" + progress: float = 0.0 + result_count: int = 0 + error_message: str = "" + started_at: datetime | None = None + completed_at: datetime | None = None + created_by_user_id: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass(slots=True) +class GeneratedVideo: + id: str + project_id: str + generation_task_id: str + name: str + file_url: str + file_size: float = 0.0 + duration: float = 0.0 + thumbnail_url: str | None = None + width: int = 0 + height: int = 0 + fps: float = 0.0 + status: str = "completed" + review_status: str = "pending_review" + generation_params: dict[str, Any] = field(default_factory=dict) + generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime | None = None + + +@dataclass(slots=True) +class EditTemplate: + id: str + project_id: str + name: str + description: str = "" + target_duration: float = 30.0 + clip_count: int = 3 + is_active: bool = True + created_by_user_id: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass(slots=True) +class EditPlan: + id: str + project_id: str + template_id: str + asset_library_id: str + title_id: str = "" + status: str = "draft" + summary: str = "" + created_by_user_id: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass(slots=True) +class ProjectTitle: + id: str + project_id: str + text: str + category: str = "default" + favorite: bool = False + usage_count: int = 0 + is_active: bool = True + created_by_user_id: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass(slots=True) +class Task: + id: str + project_id: str + name: str + description: str = "" + status: str = "pending" + priority: str = "medium" + parent_task_id: str = "" + assignee_user_id: str = "" + progress: float = 0.0 + planned_start_date: datetime | None = None + planned_end_date: datetime | None = None + actual_start_date: datetime | None = None + actual_end_date: datetime | None = None + tags: list[str] = field(default_factory=list) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass(slots=True) +class Milestone: + id: str + project_id: str + name: str + description: str = "" + target_date: datetime | None = None + completed: bool = False + completed_at: datetime | None = None + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass(slots=True) +class TaskIssue: + id: str + task_id: str + project_id: str + title: str + description: str = "" + resolved: bool = False + resolved_at: datetime | None = None + created_by_user_id: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/packages/ports/asset_library_repository.py b/packages/ports/asset_library_repository.py index 83dc95a8f..1821c1191 100644 --- a/packages/ports/asset_library_repository.py +++ b/packages/ports/asset_library_repository.py @@ -14,19 +14,10 @@ class AssetLibraryRepository(ABC): async def find_by_id(self, library_id: str) -> AssetLibrary | None: pass - @abstractmethod - async def find_by_workspace( - self, - workspace_id: str, - kind: AssetLibraryKind | None = None, - ) -> list[AssetLibrary]: - pass - @abstractmethod async def find_by_project( self, project_id: str, - workspace_id: str, ) -> list[AssetLibrary]: pass @@ -35,7 +26,7 @@ class AssetLibraryRepository(ABC): pass @abstractmethod - async def delete(self, library_id: str, workspace_id: str) -> bool: + async def delete(self, library_id: str) -> bool: pass @abstractmethod diff --git a/packages/ports/asset_repository.py b/packages/ports/asset_repository.py index 47bad8c03..14616936f 100644 --- a/packages/ports/asset_repository.py +++ b/packages/ports/asset_repository.py @@ -18,7 +18,6 @@ class AssetRepository(ABC): async def find_by_project( self, project_id: str, - workspace_id: str, skip: int = 0, limit: int = 100, ) -> list[Asset]: @@ -28,7 +27,6 @@ class AssetRepository(ABC): async def find_by_library( self, library_id: str, - workspace_id: str, skip: int = 0, limit: int = 100, ) -> list[Asset]: @@ -39,9 +37,9 @@ class AssetRepository(ABC): pass @abstractmethod - async def delete(self, asset_id: str, workspace_id: str) -> bool: + async def delete(self, asset_id: str) -> bool: pass @abstractmethod - async def count_by_project(self, project_id: str, workspace_id: str) -> int: + async def count_by_project(self, project_id: str) -> int: pass diff --git a/packages/ports/project_repository.py b/packages/ports/project_repository.py index 82867e4e8..c706fa6fc 100644 --- a/packages/ports/project_repository.py +++ b/packages/ports/project_repository.py @@ -22,8 +22,18 @@ class ProjectRepository(ABC): pass @abstractmethod - def count_by_workspace(self, workspace_id: str) -> int: - """统计工作空间的项目数量""" + def find_by_owner_user_id(self, owner_user_id: str) -> list[Project]: + """根据所有者用户 ID 查找项目""" + pass + + @abstractmethod + def find_accessible_projects(self, user_id: str) -> list[Project]: + """查找用户可访问的所有项目(自己拥有的 + 被共享的)""" + pass + + @abstractmethod + def count_by_owner(self, owner_user_id: str) -> int: + """统计用户的项目数量""" pass @abstractmethod diff --git a/packages/ports/workspace_invitation_repository.py b/packages/ports/workspace_invitation_repository.py deleted file mode 100644 index 78076e68a..000000000 --- a/packages/ports/workspace_invitation_repository.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -WorkspaceInvitation 仓储接口 -""" - -from abc import ABC, abstractmethod -from typing import Optional - -from packages.domain.entities import WorkspaceInvitation - - -class WorkspaceInvitationRepository(ABC): - """WorkspaceInvitation 仓储接口""" - - @abstractmethod - def save(self, invitation: WorkspaceInvitation) -> None: - """保存邀请""" - pass - - @abstractmethod - def find_by_id(self, invitation_id: str) -> Optional[WorkspaceInvitation]: - """根据 ID 查找邀请""" - pass - - @abstractmethod - def find_by_token(self, token: str) -> Optional[WorkspaceInvitation]: - """根据令牌查找邀请""" - pass - - @abstractmethod - def find_pending_by_workspace_and_email( - self, - workspace_id: str, - email: str, - ) -> Optional[WorkspaceInvitation]: - """查找 workspace 和邮箱的待处理邀请""" - pass - - @abstractmethod - def delete(self, invitation_id: str) -> bool: - """删除邀请""" - pass diff --git a/packages/ports/workspace_member_repository.py b/packages/ports/workspace_member_repository.py deleted file mode 100644 index ca71ff5c8..000000000 --- a/packages/ports/workspace_member_repository.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -WorkspaceMember 仓储接口 -""" - -from abc import ABC, abstractmethod -from typing import List, Optional - -from packages.domain.entities import WorkspaceMember - - -class WorkspaceMemberRepository(ABC): - """WorkspaceMember 仓储接口""" - - @abstractmethod - def save(self, member: WorkspaceMember) -> None: - """保存成员""" - pass - - @abstractmethod - def find_by_id(self, member_id: str) -> Optional[WorkspaceMember]: - """根据 ID 查找成员""" - pass - - @abstractmethod - def find_by_workspace_and_user( - self, - workspace_id: str, - user_id: str, - ) -> Optional[WorkspaceMember]: - """根据 workspace 和 user 查找成员""" - pass - - @abstractmethod - def find_by_user(self, user_id: str) -> List[WorkspaceMember]: - """查找用户的所有成员记录""" - pass - - @abstractmethod - def find_by_workspace(self, workspace_id: str) -> List[WorkspaceMember]: - """查找 workspace 的所有成员""" - pass - - @abstractmethod - def count_by_workspace(self, workspace_id: str) -> int: - """统计 workspace 的成员数量""" - pass - - @abstractmethod - def delete(self, member_id: str) -> bool: - """删除成员""" - pass diff --git a/packages/ports/workspace_repository.py b/packages/ports/workspace_repository.py deleted file mode 100644 index d1a9c77fc..000000000 --- a/packages/ports/workspace_repository.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Workspace 仓储接口 -""" - -from abc import ABC, abstractmethod -from typing import Optional - -from packages.domain.entities import Workspace - - -class WorkspaceRepository(ABC): - """Workspace 仓储接口""" - - @abstractmethod - def save(self, workspace: Workspace) -> None: - """保存 Workspace""" - pass - - @abstractmethod - def find_by_id(self, workspace_id: str) -> Optional[Workspace]: - """根据 ID 查找 Workspace""" - pass - - @abstractmethod - def delete(self, workspace_id: str) -> bool: - """删除 Workspace""" - pass diff --git a/scripts/auto_merge_prs.sh b/scripts/auto_merge_prs.sh new file mode 100755 index 000000000..e078ee05d --- /dev/null +++ b/scripts/auto_merge_prs.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# 自动合并通过 CI 检查的 PR +# 用法: ./scripts/auto_merge_prs.sh [target_branch] + +GITEA_API="https://git.xiaoxiajianji.com/api/v1" +TOKEN="1f8058d097e3942a9ed31c44382baf7f08311272" +REPO="xiaoxia/xiaoxia-saas" +TARGET_BRANCH="${1:-develop}" + +echo "=== Checking open PRs targeting $TARGET_BRANCH ===" + +# 获取所有 open PR +PRS=$(curl -s -H "Authorization: token $TOKEN" \ + "$GITEA_API/repos/$REPO/pulls?state=open&labels=0" | python3 -c " +import json, sys +data = json.load(sys.stdin) +for pr in data: + if pr.get('base', {}).get('ref') == '$TARGET_BRANCH': + if pr.get('mergeable', False): + print(f\"{pr['number']}|{pr['title']}|{pr.get('mergeable', 'unknown')}\") +") + +if [ -z "$PRS" ]; then + echo "No mergeable PRs found for $TARGET_BRANCH" + exit 0 +fi + +echo "$PRS" | while IFS='|' read -r number title mergeable; do + echo "Merging PR #$number: $title" + RESULT=$(curl -s -X POST \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + "$GITEA_API/repos/$REPO/pulls/$number/merge" \ + -d '{\"merge_method\": \"merge\"}') + + if echo "$RESULT" | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if 'id' in d else 1)"; then + echo " ✅ PR #$number merged successfully" + else + echo " ❌ PR #$number failed: $RESULT" + fi +done + +echo "=== Done ===" diff --git a/scripts/ci_failure_check.sh b/scripts/ci_failure_check.sh new file mode 100755 index 000000000..d109e6310 --- /dev/null +++ b/scripts/ci_failure_check.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# 检查最近 CI 运行状态,失败时输出告警 +GITEA_API="https://git.xiaoxiajianji.com/api/v1" +TOKEN="1f8058d097e3942a9ed31c44382baf7f08311272" +REPO="xiaoxia/xiaoxia-saas" + +echo "=== CI Status Check ===" +echo "Time: $(date '+%Y-%m-%d %H:%M:%S')" + +# 检查最近的 workflow runs +curl -s -H "Authorization: token $TOKEN" \ + "$GITEA_API/repos/$REPO/actions/runs?limit=5" 2>/dev/null | python3 -c " +import json, sys +try: + data = json.load(sys.stdin) + runs = data.get('workflow_runs', []) + if not runs: + print('No recent CI runs found') + sys.exit(0) + for run in runs[:5]: + status = run.get('status', 'unknown') + conclusion = run.get('conclusion', 'pending') + name = run.get('name', 'unknown') + created = run.get('created_at', '') + print(f' {name}: {status} ({conclusion}) - {created}') + if conclusion == 'failure': + print(f' ⚠️ WARNING: CI failed for {name}') +except: + print('Could not parse CI status') +" 2>/dev/null || echo "CI status check unavailable"