refactor: sync workspace removal to main #40

Merged
xiaoxia merged 92 commits from develop into main 2026-06-27 00:51:52 +08:00
61 changed files with 880 additions and 3209 deletions
+21
View File
@@ -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
+102 -22
View File
@@ -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'
+9 -9
View File
@@ -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
-96
View File
@@ -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}"
-414
View File
@@ -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<<EOF" >> $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 }}
-59
View File
@@ -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
@@ -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.
+15 -21
View File
@@ -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),
+19 -14
View File
@@ -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)
@@ -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,
+23 -18
View File
@@ -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])
-3
View File
@@ -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,
-16
View File
@@ -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")
-369
View File
@@ -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")
+2 -2
View File
@@ -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
-2
View File
@@ -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
-2
View File
@@ -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
@@ -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
-1
View File
@@ -11,7 +11,6 @@ class UpdateGeneratedVideoReviewRequest(BaseModel):
class GeneratedVideoResponse(BaseModel):
id: str
workspace_id: str
project_id: str
generation_task_id: str
name: str
-2
View File
@@ -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
-2
View File
@@ -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
+2 -2
View File
@@ -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):
-2
View File
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
@@ -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,
@@ -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
@@ -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,
@@ -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
@@ -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,
+5 -50
View File
@@ -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)
@@ -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
@@ -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,
)
@@ -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,
)
+2 -4
View File
@@ -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,
+1 -3
View File
@@ -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,
+7 -3
View File
@@ -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)
+9 -3
View File
@@ -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)
-2
View File
@@ -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,
+39 -8
View File
@@ -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
@@ -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",
]
@@ -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)}"
@@ -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)}"
@@ -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)}"
@@ -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)}"
@@ -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)}"
@@ -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)}"
@@ -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)}"
@@ -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)}"
+161 -70
View File
@@ -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))
+1 -10
View File
@@ -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
+2 -4
View File
@@ -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
+12 -2
View File
@@ -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
@@ -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
@@ -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
-27
View File
@@ -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
+43
View File
@@ -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 ==="
+30
View File
@@ -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"