release: V21 UI and core E2E hardening #2
@@ -36,3 +36,9 @@ DEBUG=true
|
||||
|
||||
# ==================== CORS 配置 ====================
|
||||
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
|
||||
|
||||
# ==================== 阿里云 OSS 配置 ====================
|
||||
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
||||
OSS_ACCESS_KEY_ID=your-access-key-id
|
||||
OSS_ACCESS_KEY_SECRET=your-access-key-secret
|
||||
OSS_BUCKET_NAME=xiaoxia-autocut
|
||||
|
||||
+6
-12
@@ -1,32 +1,26 @@
|
||||
# 自动检测文本文件并规范化换行符
|
||||
# Normalize text files automatically
|
||||
* text=auto
|
||||
|
||||
# Python 文件使用 LF
|
||||
# Source files use LF
|
||||
*.py text eol=lf
|
||||
|
||||
# JavaScript/TypeScript 文件使用 LF
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
|
||||
# JSON/YAML 文件使用 LF
|
||||
*.json text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
|
||||
# Markdown 文件使用 LF
|
||||
*.md text eol=lf
|
||||
|
||||
# Shell 脚本使用 LF
|
||||
*.sh text eol=lf
|
||||
infra/docker/*.sh text eol=lf
|
||||
scripts/*.sh text eol=lf
|
||||
|
||||
# Windows 批处理文件使用 CRLF
|
||||
# Windows scripts use CRLF
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
# 二进制文件
|
||||
# Binary files
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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: |
|
||||
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'
|
||||
run: |
|
||||
echo "✅ Build completed successfully!"
|
||||
echo "Branch: ${GITHUB_REF_NAME}"
|
||||
echo "Commit: ${GITHUB_SHA}"
|
||||
+228
-79
@@ -1,22 +1,22 @@
|
||||
name: Deploy
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [ main, develop ]
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
deploy-staging:
|
||||
name: Deploy Staging
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: docker:27-cli
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: runtime-builder
|
||||
if: github.ref_name == 'main' || github.ref_name == 'develop'
|
||||
|
||||
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"
|
||||
@@ -24,69 +24,109 @@ jobs:
|
||||
tar -xzf /tmp/repo.tar.gz --strip-components=1 -C .
|
||||
rm -f /tmp/repo.tar.gz
|
||||
|
||||
- name: Sync code to staging workspace
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
tar --exclude=.git -cf - . | docker run --rm -i \
|
||||
-v /:/host \
|
||||
docker:27-cli \
|
||||
sh -lc '
|
||||
set -eu
|
||||
mkdir -p /host/var/lib/xiaoxia-saas-staging
|
||||
rm -rf /host/var/lib/xiaoxia-saas-staging/repo
|
||||
mkdir -p /host/var/lib/xiaoxia-saas-staging/repo
|
||||
tar -xf - -C /host/var/lib/xiaoxia-saas-staging/repo
|
||||
'
|
||||
|
||||
- name: Verify staging env file
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm -v /:/host docker:27-cli sh -lc 'test -f /host/var/lib/xiaoxia-saas-staging/.env'
|
||||
|
||||
- name: Prepare staging env
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm -v /:/host docker:27-cli sh -lc 'cp /host/var/lib/xiaoxia-saas-staging/.env /host/var/lib/xiaoxia-saas-staging/repo/.env'
|
||||
|
||||
- name: Deploy staging stack
|
||||
- name: Build staging web artifact
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /:/host \
|
||||
docker:27-cli sh -lc '
|
||||
chmod +x /host/var/lib/xiaoxia-saas-staging/repo/infra/docker/deploy-staging.sh && \
|
||||
WEB_PORT=3001 /host/var/lib/xiaoxia-saas-staging/repo/infra/docker/deploy-staging.sh
|
||||
'
|
||||
--pull=never \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npm ci && npm run build'
|
||||
docker build --pull=false \
|
||||
-f infra/docker/web-artifact.Dockerfile \
|
||||
-t "xiaoxia-saas-web:staging-${GITHUB_SHA}" \
|
||||
.
|
||||
test -f apps/web/dist/index.html
|
||||
|
||||
- name: Verify staging health
|
||||
- name: Package staging release artifact
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
rm -rf dist/staging-artifacts
|
||||
mkdir -p dist/staging-artifacts
|
||||
tar --exclude=.git --exclude=apps/web/node_modules --exclude=./dist \
|
||||
-czf dist/staging-artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz .
|
||||
docker save -o "dist/staging-artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" "xiaoxia-saas-web:staging-${GITHUB_SHA}"
|
||||
|
||||
- name: Upload staging artifact to business host
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
staging_user="${STAGING_SSH_USER:-root}"
|
||||
mkdir -p ~/.ssh
|
||||
if [ -n "${STAGING_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
printf '%s\n' "$STAGING_SSH_KEY" > "$key_path"
|
||||
chmod 600 "$key_path"
|
||||
else
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
test -f "$key_path"
|
||||
fi
|
||||
ssh-keyscan -H "$staging_host" >> ~/.ssh/known_hosts
|
||||
ssh -i "$key_path" "$staging_user@$staging_host" "mkdir -p /var/lib/xiaoxia-saas-staging/artifacts"
|
||||
scp -i "$key_path" "dist/staging-artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz" \
|
||||
"$staging_user@$staging_host:/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz"
|
||||
scp -i "$key_path" "dist/staging-artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" \
|
||||
"$staging_user@$staging_host:/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar"
|
||||
|
||||
- name: Deploy staging stack on business host
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
staging_user="${STAGING_SSH_USER:-root}"
|
||||
if [ -n "${STAGING_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
else
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
fi
|
||||
ssh -i "$key_path" "$staging_user@$staging_host" "GITHUB_SHA='${GITHUB_SHA}' sh -s" <<'REMOTE_DEPLOY'
|
||||
set -eu
|
||||
artifact="/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz"
|
||||
image_tar="/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar"
|
||||
test -f "$artifact"
|
||||
test -f "$image_tar"
|
||||
test -f /var/lib/xiaoxia-saas-staging/.env
|
||||
docker load -i "$image_tar"
|
||||
rm -rf /var/lib/xiaoxia-saas-staging/repo
|
||||
mkdir -p /var/lib/xiaoxia-saas-staging/repo
|
||||
tar -xzf "$artifact" -C /var/lib/xiaoxia-saas-staging/repo
|
||||
test -f /var/lib/xiaoxia-saas-staging/repo/apps/web/dist/index.html
|
||||
cp /var/lib/xiaoxia-saas-staging/.env /var/lib/xiaoxia-saas-staging/repo/.env
|
||||
chmod +x /var/lib/xiaoxia-saas-staging/repo/infra/docker/deploy-staging.sh
|
||||
WEB_IMAGE="xiaoxia-saas-web:staging-${GITHUB_SHA}" HOST_PREFIX= WEB_PORT=3001 REBUILD_BACKEND=0 BUILD_WEB=0 RUN_MIGRATIONS=0 /var/lib/xiaoxia-saas-staging/repo/infra/docker/deploy-staging.sh
|
||||
i=0
|
||||
while [ "$i" -lt 30 ]; do
|
||||
if wget -qO- http://127.0.0.1:8000/api/v1/health; then
|
||||
if wget -qO- http://127.0.0.1:8000/health; then
|
||||
exit 0
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
exit 1
|
||||
REMOTE_DEPLOY
|
||||
|
||||
deploy-production:
|
||||
name: Deploy Production
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: docker:27-cli
|
||||
build-production-runtime-images:
|
||||
name: Build Production Runtime Images
|
||||
runs-on: runtime-builder
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
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"
|
||||
@@ -94,55 +134,164 @@ jobs:
|
||||
tar -xzf /tmp/repo.tar.gz --strip-components=1 -C .
|
||||
rm -f /tmp/repo.tar.gz
|
||||
|
||||
- name: Sync code to production workspace
|
||||
- name: Build runtime image artifact
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
tar --exclude=.git -cf - . | docker run --rm -i \
|
||||
-v /:/host \
|
||||
docker:27-cli \
|
||||
sh -lc '
|
||||
set -eu
|
||||
mkdir -p /host/var/lib/xiaoxia-saas-production
|
||||
rm -rf /host/var/lib/xiaoxia-saas-production/repo
|
||||
mkdir -p /host/var/lib/xiaoxia-saas-production/repo
|
||||
tar -xf - -C /host/var/lib/xiaoxia-saas-production/repo
|
||||
'
|
||||
chmod +x scripts/build_release_images.sh
|
||||
scripts/build_release_images.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Verify production env file
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm -v /:/host docker:27-cli sh -lc 'test -f /host/var/lib/xiaoxia-saas-production/.env'
|
||||
|
||||
- name: Prepare production env
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm -v /:/host docker:27-cli sh -lc 'cp /host/var/lib/xiaoxia-saas-production/.env /host/var/lib/xiaoxia-saas-production/repo/.env'
|
||||
|
||||
- name: Deploy production stack
|
||||
- name: Build production web artifact
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /:/host \
|
||||
docker:27-cli sh -lc '
|
||||
chmod +x /host/var/lib/xiaoxia-saas-production/repo/infra/docker/deploy-production.sh && \
|
||||
WEB_PORT=3001 /host/var/lib/xiaoxia-saas-production/repo/infra/docker/deploy-production.sh
|
||||
'
|
||||
--pull=never \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npm ci && npm run build'
|
||||
docker build --pull=false \
|
||||
-f infra/docker/web-artifact.Dockerfile \
|
||||
-t "xiaoxia-saas-web:${GITHUB_REF_NAME}" \
|
||||
.
|
||||
test -f apps/web/dist/index.html
|
||||
|
||||
- name: Verify production health
|
||||
- name: Package release source artifact
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist/release-artifacts
|
||||
tar --exclude=.git --exclude=apps/web/node_modules --exclude=./dist \
|
||||
-czf "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" .
|
||||
docker save -o "dist/release-artifacts/xiaoxia-web-${GITHUB_REF_NAME}.tar" "xiaoxia-saas-web:${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Upload runtime image and release artifacts
|
||||
shell: sh
|
||||
env:
|
||||
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
|
||||
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
|
||||
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
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"
|
||||
test -f "$key_path"
|
||||
fi
|
||||
ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts
|
||||
scp -i "$key_path" "dist/release-images/xiaoxia-runtime-images-${GITHUB_REF_NAME}.tar" \
|
||||
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/runtime-images-${GITHUB_REF_NAME}.tar"
|
||||
scp -i "$key_path" "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" \
|
||||
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/release-${GITHUB_REF_NAME}.tar.gz"
|
||||
scp -i "$key_path" "dist/release-artifacts/xiaoxia-web-${GITHUB_REF_NAME}.tar" \
|
||||
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/web-${GITHUB_REF_NAME}.tar"
|
||||
|
||||
deploy-production:
|
||||
name: Deploy Production
|
||||
runs-on: runtime-builder
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: build-production-runtime-images
|
||||
|
||||
steps:
|
||||
- name: Deploy production over SSH
|
||||
shell: sh
|
||||
env:
|
||||
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
|
||||
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
|
||||
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
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"
|
||||
test -f "$key_path"
|
||||
fi
|
||||
ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts
|
||||
ssh -i "$key_path" "$production_user@$production_host" \
|
||||
"RELEASE_VERSION='${GITHUB_REF_NAME}' sh -s" <<'REMOTE_DEPLOY'
|
||||
set -eu
|
||||
release_tar="/var/lib/xiaoxia-saas-production/release-${RELEASE_VERSION}.tar.gz"
|
||||
test -f "$release_tar"
|
||||
test -f "/var/lib/xiaoxia-saas-production/runtime-images-${RELEASE_VERSION}.tar"
|
||||
test -f "/var/lib/xiaoxia-saas-production/web-${RELEASE_VERSION}.tar"
|
||||
mkdir -p /var/lib/xiaoxia-saas-production
|
||||
old_assets_dir="/tmp/xiaoxia-previous-web-assets-${RELEASE_VERSION}"
|
||||
rm -rf "$old_assets_dir"
|
||||
mkdir -p "$old_assets_dir"
|
||||
if docker inspect xiaoxia-web-production >/dev/null 2>&1; then
|
||||
docker cp xiaoxia-web-production:/usr/share/nginx/html/assets/. "$old_assets_dir"/ 2>/dev/null || true
|
||||
fi
|
||||
if [ -d /var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets ]; then
|
||||
cp -a /var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets/. "$old_assets_dir"/
|
||||
fi
|
||||
rm -rf /var/lib/xiaoxia-saas-production/repo
|
||||
mkdir -p /var/lib/xiaoxia-saas-production/repo
|
||||
tar -xzf "$release_tar" -C /var/lib/xiaoxia-saas-production/repo
|
||||
test -f /var/lib/xiaoxia-saas-production/repo/apps/web/dist/index.html
|
||||
if [ -d "$old_assets_dir" ]; then
|
||||
mkdir -p /var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets
|
||||
for asset in "$old_assets_dir"/*; do
|
||||
[ -e "$asset" ] || continue
|
||||
name="$(basename "$asset")"
|
||||
if [ ! -e "/var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets/$name" ]; then
|
||||
cp -a "$asset" "/var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets/$name"
|
||||
fi
|
||||
done
|
||||
rm -rf "$old_assets_dir"
|
||||
fi
|
||||
test -f /var/lib/xiaoxia-saas-production/.env
|
||||
cp /var/lib/xiaoxia-saas-production/.env /var/lib/xiaoxia-saas-production/repo/.env
|
||||
HOST_PREFIX= WEB_IMAGE="xiaoxia-saas-web:${RELEASE_VERSION}" WEB_IMAGE_TAR="/var/lib/xiaoxia-saas-production/web-${RELEASE_VERSION}.tar" sh /var/lib/xiaoxia-saas-production/repo/infra/docker/deploy-production.sh
|
||||
i=0
|
||||
while [ "$i" -lt 30 ]; do
|
||||
if wget -qO- http://127.0.0.1:8000/api/v1/health; then
|
||||
if wget -qO- http://127.0.0.1:8001/health; then
|
||||
exit 0
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
exit 1
|
||||
REMOTE_DEPLOY
|
||||
|
||||
production-e2e:
|
||||
name: Production Browser E2E
|
||||
runs-on: runtime-builder
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: deploy-production
|
||||
|
||||
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: Run production browser E2E
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
-e E2E_BASE_URL=https://saas.xiaoxiajianji.com \
|
||||
-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/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Tests
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: python:3.12-slim
|
||||
image: xiaoxia-ci-python:3.12
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -58,12 +58,12 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m pytest tests/integration/ -v --cov=packages --cov=apps --cov-report=xml --cov-report=term
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/unit -q
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: python:3.12-slim
|
||||
image: xiaoxia-ci-python:3.12
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -104,16 +104,10 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m black --check packages/ apps/ tests/
|
||||
python -m black --check alembic apps packages tests scripts
|
||||
|
||||
- name: Run Flake8
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m flake8 packages/ apps/ tests/ --max-line-length=120 --extend-ignore=E203,W503
|
||||
|
||||
- name: Run MyPy
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m mypy packages/ apps/ --ignore-missing-imports
|
||||
python -m flake8 apps packages tests --count --statistics
|
||||
|
||||
+83
-119
@@ -2,131 +2,95 @@ name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- 'feature/**'
|
||||
- 'bugfix/**'
|
||||
- 'hotfix/**'
|
||||
- 'release/**'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
validate:
|
||||
name: Validate Code Quality And Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.11', '3.12']
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Cache pip packages
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pytest pytest-cov pytest-asyncio
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
USE_IN_MEMORY_DB: true
|
||||
run: |
|
||||
pytest tests/ -v --cov=packages --cov-report=xml --cov-report=term
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
container: xiaoxia-ci-python:3.12
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
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 linting tools
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install black isort flake8 mypy
|
||||
|
||||
- name: Check code formatting with black
|
||||
run: black --check packages/ apps/ tests/
|
||||
|
||||
- name: Check import sorting with isort
|
||||
run: isort --check-only packages/ apps/ tests/
|
||||
|
||||
- name: Lint with flake8
|
||||
run: flake8 packages/ apps/ tests/ --max-line-length=100 --ignore=E203,W503
|
||||
- name: Checkout code
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os
|
||||
import tarfile
|
||||
import urllib.request
|
||||
|
||||
build:
|
||||
name: Build Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, lint]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: xiaoxia-saas:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
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
|
||||
|
||||
deploy-staging:
|
||||
name: Deploy to Staging
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to staging
|
||||
run: |
|
||||
echo "Deploying to staging environment..."
|
||||
# Add your staging deployment commands here
|
||||
# Example: ssh deploy@staging-server 'cd /app && docker-compose pull && docker-compose up -d'
|
||||
- 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"
|
||||
|
||||
deploy-production:
|
||||
name: Deploy to Production
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to production
|
||||
run: |
|
||||
echo "Deploying to production environment..."
|
||||
# Add your production deployment commands here
|
||||
# Example: ssh deploy@prod-server 'cd /app && docker-compose pull && docker-compose up -d'
|
||||
- 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}"
|
||||
|
||||
@@ -8,6 +8,7 @@ coverage/
|
||||
# Python / backend
|
||||
.venv/
|
||||
venv/
|
||||
.venv-ci-root/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Errors
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 2026-06-24 ProjectAssets unsafe return replacement
|
||||
- Context: Real SaaS UI rollout from V21 prototype.
|
||||
- Error: Replacing JSX return by broad string/script inserted helper functions inside an effect and broke TypeScript syntax.
|
||||
- Fix: Reverted ProjectAssets.tsx to stable git version; continue with smaller, scoped edits or separate page files.
|
||||
- Lesson: For large TSX pages with effects, avoid broad find/replace from first return; use component-scope anchors or rewrite whole file intentionally.
|
||||
|
||||
|
||||
## 2026-06-24 E2E API unavailable
|
||||
- Context: V21 UI acceptance run.
|
||||
- Failure: Playwright core upload/generation/titles failed at auth/register with 500 because Vite proxy could not connect to local API (ECONNREFUSED).
|
||||
- Fix path: Start local API or point E2E_BASE_URL/API proxy to staging test environment before rerunning core E2E.
|
||||
|
||||
|
||||
## ERR-20260624-gitea-runner-fetch-task-404
|
||||
|
||||
**Logged**: 2026-06-24T19:27+08:00
|
||||
**Area**: infra/ci
|
||||
|
||||
### Summary
|
||||
Gitea Actions runner is running but repeatedly logs ailed to fetch task: unimplemented: 404 Not Found; develop pushes appear in Actions UI but staging repo is not updated.
|
||||
|
||||
### Impact
|
||||
CI/CD-first release is blocked until runner/Gitea endpoint compatibility or registration is fixed.
|
||||
|
||||
### Next Action
|
||||
Check act_runner config/registration, Gitea actions endpoint compatibility, runner version, and service URL.
|
||||
|
||||
|
||||
## [ERR-20260624-STAGING-WEB-BUILD-ON-BUSINESS-SERVER] deploy
|
||||
|
||||
**Logged**: 2026-06-24T22:50:00+08:00
|
||||
**Priority**: critical
|
||||
**Status**: pending
|
||||
**Area**: infra
|
||||
|
||||
### Summary
|
||||
Staging artifact upgrade attempted `npm ci && npm run build` on the wrong server path and overloaded the machine.
|
||||
|
||||
### Details
|
||||
The deploy workflow change `3bffa3c fix(deploy): build staging web artifact` added a staging step that ran Node build via Docker on the runner/deploy host. SSH later connected at TCP level but timed out during banner exchange; public HTTPS/health also timed out. The dangerous workflow was reverted by `b01ae28 Revert "fix(deploy): build staging web artifact"`.
|
||||
|
||||
### Suggested Action
|
||||
Recover host first, stop residual build/runner tasks, verify production/staging health, then reimplement artifact deploy using isolated builder/CI server and hard resource limits. Add explicit guardrails so business server cannot run npm/pip/docker builds.
|
||||
|
||||
### Metadata
|
||||
- Source: error
|
||||
- Related Files: .gitea/workflows/deploy.yml, docs/V21-UI-ACCEPTANCE-CHECKLIST.md
|
||||
- Tags: outage, ci-cd, resource-isolation, rollback
|
||||
---
|
||||
|
||||
## 2026-06-25 - Alembic command must use repo root in API container
|
||||
|
||||
- Failed command: docker compose exec api alembic upgrade head from mounted repo path inside staging deploy directory.
|
||||
- Error: No config file alembic.ini found because the API container workdir is /app/apps/api while alembic.ini is /app/alembic.ini.
|
||||
- Fix: run docker exec -w /app xiaoxia-api-staging alembic -c alembic.ini upgrade head for lightweight staging migrations.
|
||||
|
||||
|
||||
## 2026-06-25 - Windows workspace has no local sh/bash
|
||||
|
||||
- Failed command: sh -n infra/docker/deploy-production.sh / bash -n infra/docker/deploy-production.sh on Windows host.
|
||||
- Error: sh/bash command not found in the PowerShell runtime.
|
||||
- Fix: run POSIX shell syntax checks via an available Linux host/container, e.g. scp to xiaoxia-server and run sh -n on a temporary file.
|
||||
|
||||
|
||||
## 2026-06-25 - Protected main release must not be direct-merged locally
|
||||
|
||||
- Failed action: attempted local develop->main merge and tag push for v0.1.51.
|
||||
- Errors: main branch is protected from direct push; local main had divergence/conflicts; tag v0.1.51 was pushed from the wrong local main HEAD and then removed.
|
||||
- Fix: never tag production before protected main has accepted the release commit. Use PR/approved merge path or Gitea API merge, then tag the actual merged main commit.
|
||||
|
||||
|
||||
## 2026-06-25 - No local Gitea/GitHub CLI in Windows workspace
|
||||
|
||||
- Failed command: gh --version / tea --version / gitea --version during release automation.
|
||||
- Error: commands not found in PowerShell runtime.
|
||||
- Fix: use Gitea API/server-side tools when available, or the web PR flow for protected-branch releases.
|
||||
|
||||
|
||||
## 2026-06-25 - Gitea generated token returned API 401
|
||||
|
||||
- Failed operation: create release PR via server-side generated Gitea access token.
|
||||
- Error: API returned 401 on authenticated pull request query/create.
|
||||
- Fix: verify token output/scopes/API auth behavior before using; do not print secrets, and delete temporary tokens after failed attempts.
|
||||
|
||||
|
||||
## 2026-06-25 - Business Gitea host lacks runtime-builder SSH key for ref sync
|
||||
|
||||
- Failed command: git fetch from git.xiaoxiajianji.com:2222 inside /var/lib/gitea/data/gitea-repositories using /root/.ssh/xiaoxia_runtime_builder.
|
||||
- Error: identity file missing and Permission denied (publickey).
|
||||
- Fix: do not install keys ad hoc on the business host; use an already-authenticated local clone bundle or proper Git/Gitea maintenance path to sync refs.
|
||||
|
||||
|
||||
## 2026-06-25 - Non-ASCII comments in .gitattributes broke Git attribute parsing
|
||||
|
||||
- Error: Git printed 'is not a valid attribute name' for Chinese comment text in .gitattributes during merge/fetch operations.
|
||||
- Fix: keep .gitattributes comments/rules ASCII-only and preserve the LF/CRLF normalization semantics.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
|
||||
## 2026-06-24 correction: strict V21 UI implementation
|
||||
- Category: correction
|
||||
- User correction: Real SaaS UI must strictly follow confirmed V21 prototype, not agent-designed approximations.
|
||||
- Specific issue: Chinese mojibake appeared; generated video library lacked built-in playable preview required by design.
|
||||
- Required behavior: Re-read confirmed prototype before UI implementation, map layout/function one-to-one, preserve approved layout and only adapt real data/API.
|
||||
|
||||
|
||||
## 2026-06-24 correction: do not ask for next step during auto-run
|
||||
- Category: correction
|
||||
- User correction: When there is an obvious next step in full-auto mode, do not ask; continue until done, validate, and deploy.
|
||||
- Required behavior: For V21 SaaS UI rollout, autonomously finish all remaining pages, then report concise results only.
|
||||
|
||||
|
||||
## [LRN-20260624-CI-SEPARATION] correction
|
||||
|
||||
**Logged**: 2026-06-24T22:50:00+08:00
|
||||
**Priority**: critical
|
||||
**Status**: pending
|
||||
**Area**: infra
|
||||
|
||||
### Summary
|
||||
Do not run CI/Web build on the business/production server; preserve the two-server responsibility split.
|
||||
|
||||
### Details
|
||||
User corrected that the project already had two servers and had already addressed mixed responsibilities. The failure happened because I ignored the established boundary and triggered `npm ci && npm run build` through the current runner/deploy path, which pressured the business server and caused SSH banner and public service timeouts. This is an execution drift, not a product-size problem.
|
||||
|
||||
### Suggested Action
|
||||
Before any deploy/build change, verify server roles and runner placement. CI/build must run on the CI/build server or isolated builder; business server may only receive built artifacts/images and restart services. Never reintroduce build workloads onto production/business host.
|
||||
|
||||
### Metadata
|
||||
- Source: user_feedback
|
||||
- Related Files: .gitea/workflows/deploy.yml, infra/docker/deploy-staging.sh
|
||||
- Tags: ci-cd, staging, production-safety, server-roles, no-drift
|
||||
- Pattern-Key: infra.separate_ci_from_business_server
|
||||
- Recurrence-Count: 1
|
||||
---
|
||||
@@ -5,6 +5,69 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.0] - 2026-06-19
|
||||
|
||||
### Phase 7: 核心视频剪辑业务 - 完成 ✅
|
||||
|
||||
**完成进度:** 100%
|
||||
**状态:** 已完成并验证
|
||||
|
||||
#### Added
|
||||
|
||||
**素材管理:**
|
||||
- 素材上传与存储(MinIO)
|
||||
- 素材列表与查询
|
||||
- 素材标签管理
|
||||
- 素材库管理
|
||||
- 素材分类功能
|
||||
|
||||
**视频生成:**
|
||||
- 生成任务创建
|
||||
- Celery worker 自动触发
|
||||
- 生成结果管理
|
||||
- 生成进度查询
|
||||
|
||||
**成片下载:**
|
||||
- 预签名下载 URL
|
||||
- 规范化存储路径(workspace/project/task)
|
||||
- 下载链接有效期管理
|
||||
|
||||
**前端联调:**
|
||||
- 生成页面(ProjectGeneration.tsx)
|
||||
- 结果页面(ProjectResults.tsx)
|
||||
- API 客户端(generation.ts)
|
||||
|
||||
#### Fixed
|
||||
|
||||
**代码质量:**
|
||||
- 清理所有 TODO(session_id in JWT, repository injection)
|
||||
- 修复 worker 中的 repository 注入
|
||||
- 完善 JWT payload 包含 session_id
|
||||
|
||||
**文档:**
|
||||
- 修复 README.md UTF-8 乱码问题
|
||||
- 创建 API-MAINLINE.md(68+ endpoints)
|
||||
- 创建 CODE-STATUS.md(代码状态标注)
|
||||
- 更新 saas-index.md(现代导航结构)
|
||||
|
||||
### 专项工作
|
||||
|
||||
**专项 A: CI/CD 稳定性修复 - 完成 ✅**
|
||||
- 修复质量检查工具链
|
||||
- 统一 .gitea 和 .github workflows
|
||||
- 建立 runner 基础设施治理
|
||||
- CI 从不稳定收敛为可靠基础设施
|
||||
|
||||
**专项 B: 全仓主线路径澄清 - 完成 ✅**
|
||||
- 创建 API 主线清单文档
|
||||
- 标注所有代码状态(ACTIVE/COMPAT/DEPRECATED)
|
||||
- 测试分类清单
|
||||
- 快速定位指南
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2026-06-17
|
||||
|
||||
### Phase 4: SAAS 产品化 - 完成
|
||||
|
||||
+13
-29
@@ -1,32 +1,16 @@
|
||||
# 小虾 SaaS - Python 后端
|
||||
FROM python:3.12-slim
|
||||
# Deprecated root Dockerfile
|
||||
#
|
||||
# The canonical SaaS runtime images live under infra/docker/:
|
||||
# - infra/docker/api.Dockerfile
|
||||
# - infra/docker/worker.Dockerfile
|
||||
# - infra/docker/web.Dockerfile
|
||||
#
|
||||
# Use infra/docker/compose.yml and infra/docker/deploy-staging.sh for deployments.
|
||||
# This file intentionally fails to prevent accidental use of the old root build path.
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
FROM scratch
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
LABEL org.opencontainers.image.title="xiaoxia-saas-deprecated-root-dockerfile"
|
||||
LABEL org.opencontainers.image.description="Use infra/docker/api.Dockerfile instead"
|
||||
|
||||
# 复制依赖文件
|
||||
COPY requirements.txt .
|
||||
|
||||
# 安装 Python 依赖
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 复制应用代码
|
||||
COPY packages/ ./packages/
|
||||
COPY apps/ ./apps/
|
||||
COPY migrations/ ./migrations/
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||
|
||||
# 启动命令
|
||||
CMD ["uvicorn", "apps.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
RUN false
|
||||
|
||||
@@ -9,7 +9,13 @@
|
||||
|
||||
---
|
||||
|
||||
## ✨ 特性
|
||||
## ✨ 核心特性
|
||||
|
||||
### 🎬 视频剪辑主链路(Phase 7 已完成)
|
||||
- 素材上传与管理
|
||||
- AI 智能分类
|
||||
- 自动化视频生成
|
||||
- 成片下载与管理
|
||||
|
||||
### 🔐 完整的认证系统
|
||||
- JWT 认证(access + refresh token)
|
||||
@@ -23,7 +29,7 @@
|
||||
- 基于角色的权限控制(Owner/Admin/Member/Viewer)
|
||||
- 邀请和审批流程
|
||||
|
||||
### 💳 订阅管理
|
||||
### 💸 订阅管理
|
||||
- 3 级订阅计划(Free/Pro/Enterprise)
|
||||
- 配额管理(项目数/存储空间)
|
||||
- 升级和取消订阅
|
||||
@@ -34,12 +40,6 @@
|
||||
- 慢查询检测
|
||||
- 健康检查(Kubernetes 就绪)
|
||||
|
||||
### 📚 完整文档
|
||||
- API 文档(Swagger/ReDoc)
|
||||
- 部署指南
|
||||
- 性能优化指南
|
||||
- 11+ 篇技术文档
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
@@ -74,55 +74,36 @@ source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
# 3. 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 4. 使用内存数据库(无需 PostgreSQL)
|
||||
echo "USE_IN_MEMORY_DB=true" > .env
|
||||
|
||||
# 5. 启动开发服务器
|
||||
# 4. 启动开发服务器
|
||||
uvicorn apps.api.main:app --reload
|
||||
|
||||
# 6. 访问 API 文档
|
||||
# 5. 访问 API 文档
|
||||
open http://localhost:8000/docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 API 示例
|
||||
## 📚 主要文档
|
||||
|
||||
### 注册用户
|
||||
### 快速入口
|
||||
- **[API 主线清单](docs/API-MAINLINE.md)** - 所有 API endpoints 总览
|
||||
- **[代码状态标注](docs/CODE-STATUS.md)** - 代码库导航指南
|
||||
- **[API 使用指南](docs/API-GUIDE.md)** - API 详细用法
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"password": "SecurePass123",
|
||||
"username": "myuser",
|
||||
"display_name": "My Name"
|
||||
}'
|
||||
```
|
||||
### Phase 7 交付
|
||||
- **[Phase 7 进度](docs/PHASE7-PROGRESS.md)** - 核心视频剪辑业务已完成
|
||||
- **[Phase 7 设计](docs/PHASE7-DESIGN.md)** - 设计文档
|
||||
|
||||
### 登录
|
||||
### 基础设施
|
||||
- **[Docker 部署指南](docs/DOCKER-DEPLOYMENT.md)**
|
||||
- **[CI/CD 文档](docs/CI-CD.md)**
|
||||
- **[性能监控指南](docs/PERFORMANCE-MONITORING.md)**
|
||||
- **[健康检查指南](docs/HEALTH-CHECKS.md)**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"password": "SecurePass123"
|
||||
}'
|
||||
```
|
||||
|
||||
### 创建工作空间
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/workspaces \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "我的团队",
|
||||
"subscription_plan": "free"
|
||||
}'
|
||||
```
|
||||
### 开发规范
|
||||
- **[Git 工作流](docs/GIT-WORKFLOW.md)**
|
||||
- **[环境配置指南](docs/ENVIRONMENT-CONFIG.md)**
|
||||
- **[贡献指南](CONTRIBUTING.md)**
|
||||
|
||||
---
|
||||
|
||||
@@ -136,11 +117,13 @@ curl -X POST http://localhost:8000/api/v1/workspaces \
|
||||
│ ├── ports/ # 接口定义
|
||||
│ └── adapters/ # 适配器实现
|
||||
├── apps/ # 应用层
|
||||
│ └── api/ # FastAPI 应用
|
||||
│ ├── api/ # FastAPI 应用
|
||||
│ ├── web/ # React 前端
|
||||
│ └── worker/ # Celery worker
|
||||
├── migrations/ # 数据库迁移
|
||||
├── tests/ # 测试
|
||||
│ ├── unit/ # 单元测试(170 个)
|
||||
│ └── integration/ # 集成测试(12 个)
|
||||
│ ├── unit/ # 单元测试
|
||||
│ └── integration/ # 集成测试
|
||||
└── docs/ # 文档
|
||||
```
|
||||
|
||||
@@ -152,28 +135,37 @@ curl -X POST http://localhost:8000/api/v1/workspaces \
|
||||
|
||||
---
|
||||
|
||||
## 🔧 配置
|
||||
## 🔑 核心 API 路径
|
||||
|
||||
### 环境变量
|
||||
### 视频剪辑主链路(Phase 7)
|
||||
|
||||
```env
|
||||
# 数据库切换
|
||||
USE_IN_MEMORY_DB=true # 开发环境(无需 PostgreSQL)
|
||||
USE_IN_MEMORY_DB=false # 生产环境(使用 PostgreSQL)
|
||||
```
|
||||
上传素材
|
||||
↓
|
||||
POST /api/v1/upload
|
||||
|
||||
# 数据库连接
|
||||
DATABASE_URL=postgresql://user:pass@localhost:5432/xiaoxia_saas
|
||||
创建 Asset
|
||||
↓
|
||||
POST /api/v1/assets
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET_KEY=your-secret-key-at-least-32-chars
|
||||
发起分类
|
||||
↓
|
||||
POST /api/v1/classification-jobs
|
||||
|
||||
# 邮件配置
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASSWORD=your-app-password
|
||||
创建生成任务
|
||||
↓
|
||||
POST /api/v1/generation/tasks
|
||||
|
||||
查询生成结果
|
||||
↓
|
||||
GET /api/v1/generation/tasks/{task_id}/results
|
||||
|
||||
获取下载链接
|
||||
↓
|
||||
GET /api/v1/generated-videos/{video_id}/download-url
|
||||
```
|
||||
|
||||
完整配置参考 `.env.example`
|
||||
完整 API 列表请查看 [API 主线清单](docs/API-MAINLINE.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -186,88 +178,35 @@ pytest tests/ -v
|
||||
# 运行单元测试
|
||||
pytest tests/unit -v
|
||||
|
||||
# 运行集成测试
|
||||
pytest tests/integration -v
|
||||
|
||||
# 生成覆盖率报告
|
||||
pytest --cov=packages --cov-report=html
|
||||
|
||||
# 查看覆盖率
|
||||
open htmlcov/index.html
|
||||
```
|
||||
|
||||
**测试统计:**
|
||||
- 单元测试: 170 个 ✅
|
||||
- 测试覆盖率: 85%+
|
||||
- 集成测试: 12 个
|
||||
- 单元测试: 17+ 个 ✅
|
||||
- 集成测试: 6+ 个 ✅
|
||||
- 测试覆盖率: 持续提升中
|
||||
|
||||
---
|
||||
|
||||
## 📚 文档
|
||||
|
||||
- [API 使用指南](docs/API-GUIDE.md)
|
||||
- [Docker 部署指南](docs/DOCKER-DEPLOYMENT.md)
|
||||
- [数据库切换指南](docs/DATABASE-SWITCH.md)
|
||||
- [性能监控指南](docs/PERFORMANCE-MONITORING.md)
|
||||
- [环境配置指南](docs/ENVIRONMENT-CONFIG.md)
|
||||
- [健康检查指南](docs/HEALTH-CHECKS.md)
|
||||
- [分页使用指南](docs/PAGINATION.md)
|
||||
- [生产部署检查清单](docs/PRODUCTION-CHECKLIST.md)
|
||||
- [贡献指南](CONTRIBUTING.md)
|
||||
|
||||
---
|
||||
|
||||
## 🚢 部署
|
||||
|
||||
### Kubernetes
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: xiaoxia-api
|
||||
spec:
|
||||
replicas: 3
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: xiaoxia-saas:latest
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
port: 8000
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
api:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://...
|
||||
- REDIS_URL=redis://...
|
||||
```
|
||||
|
||||
查看 [完整部署指南](docs/DOCKER-DEPLOYMENT.md)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 技术栈
|
||||
## 🛠️ 技术栈
|
||||
|
||||
**后端:**
|
||||
- Python 3.12
|
||||
- FastAPI 0.115.0
|
||||
- Pydantic 2.9
|
||||
- PostgreSQL 16
|
||||
- Redis 7
|
||||
- Celery
|
||||
- MinIO
|
||||
|
||||
**前端:**
|
||||
- React 18
|
||||
- TypeScript
|
||||
- Vite
|
||||
- Ant Design
|
||||
|
||||
**测试:**
|
||||
- pytest
|
||||
@@ -277,19 +216,20 @@ services:
|
||||
**部署:**
|
||||
- Docker
|
||||
- Docker Compose
|
||||
- Kubernetes(可选)
|
||||
- Gitea Actions
|
||||
|
||||
---
|
||||
|
||||
## 📊 性能
|
||||
## 📊 当前状态
|
||||
|
||||
| 指标 | 数值 |
|
||||
| 模块 | 状态 |
|
||||
|------|------|
|
||||
| API 平均响应时间 | < 50ms |
|
||||
| 数据库查询时间 | < 10ms |
|
||||
| 并发支持 | 1000+ RPS |
|
||||
| 连接池性能提升 | 5-6x |
|
||||
| 测试覆盖率 | 85%+ |
|
||||
| Phase 7(视频剪辑主链) | ✅ 已完成 |
|
||||
| CI/CD 稳定性 | ✅ 已修复 |
|
||||
| 认证系统 | ✅ 完整 |
|
||||
| 多租户架构 | ✅ 完整 |
|
||||
| 订阅系统 | ✅ 完整 |
|
||||
| 前端联调 | ✅ 完成 |
|
||||
|
||||
---
|
||||
|
||||
@@ -313,9 +253,8 @@ services:
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
- **文档:** https://docs.xiaoxia-saas.com
|
||||
- **问题反馈:** GitHub Issues
|
||||
- **邮箱:** support@xiaoxia-saas.com
|
||||
- **文档:** 参考 `docs/` 目录
|
||||
|
||||
---
|
||||
|
||||
@@ -328,3 +267,8 @@ services:
|
||||
---
|
||||
|
||||
**⭐ 如果这个项目对你有帮助,请给一个 Star!**
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-06-19
|
||||
**当前版本**: Phase 7 完成
|
||||
|
||||
+9
-3
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
@@ -12,6 +12,10 @@ from packages.adapters.sqlalchemy_impl.models import Base
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if database_url:
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
@@ -66,7 +70,9 @@ def run_migrations_online() -> None:
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Current SQLAlchemy schema baseline.
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-06-21
|
||||
|
||||
This revision represents the current runtime schema defined by
|
||||
packages.adapters.sqlalchemy_impl.models. Existing staging databases should be
|
||||
stamped to this revision after compatibility verification; fresh databases can
|
||||
run this migration normally.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("email", sa.String(length=255), nullable=False),
|
||||
sa.Column("username", sa.String(length=100), nullable=True),
|
||||
sa.Column("display_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||
sa.Column("email_verified", sa.Boolean(), nullable=False),
|
||||
sa.Column("email_verification_token", sa.String(length=255), nullable=True),
|
||||
sa.Column("password_reset_token", sa.String(length=255), nullable=True),
|
||||
sa.Column("password_reset_expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_login_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_login_ip", sa.String(length=50), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
||||
op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"projects",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("name", sa.String(length=100), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_projects_workspace_id"), "projects", ["workspace_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"asset_libraries",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("asset_count", sa.Float(), nullable=False),
|
||||
sa.Column("total_size", sa.Float(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_asset_libraries_kind"), "asset_libraries", ["kind"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_asset_libraries_project_id"),
|
||||
"asset_libraries",
|
||||
["project_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_asset_libraries_workspace_id"),
|
||||
"asset_libraries",
|
||||
["workspace_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"assets",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("asset_library_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("name", sa.String(length=500), nullable=False),
|
||||
sa.Column("file_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("file_size", sa.Float(), nullable=False),
|
||||
sa.Column("file_url", sa.String(length=1000), nullable=False),
|
||||
sa.Column("thumbnail_url", sa.String(length=1000), nullable=True),
|
||||
sa.Column("duration", sa.Float(), nullable=True),
|
||||
sa.Column("width", sa.Float(), nullable=True),
|
||||
sa.Column("height", sa.Float(), nullable=True),
|
||||
sa.Column("fps", sa.Float(), nullable=True),
|
||||
sa.Column("codec", sa.String(length=50), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("classification_status", sa.String(length=20), nullable=False),
|
||||
sa.Column("classification_result", sa.Text(), nullable=True),
|
||||
sa.Column("quality_score", sa.Float(), nullable=True),
|
||||
sa.Column("uploaded_by_user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_assets_asset_library_id"), "assets", ["asset_library_id"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_assets_classification_status"),
|
||||
"assets",
|
||||
["classification_status"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(op.f("ix_assets_created_at"), "assets", ["created_at"], unique=False)
|
||||
op.create_index(op.f("ix_assets_file_type"), "assets", ["file_type"], unique=False)
|
||||
op.create_index(op.f("ix_assets_project_id"), "assets", ["project_id"], unique=False)
|
||||
op.create_index(op.f("ix_assets_status"), "assets", ["status"], unique=False)
|
||||
op.create_index(op.f("ix_assets_workspace_id"), "assets", ["workspace_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"ingest_jobs",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("library_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("storage_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("error_message", sa.Text(), nullable=False),
|
||||
sa.Column("result_asset_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_ingest_jobs_library_id"), "ingest_jobs", ["library_id"], unique=False)
|
||||
op.create_index(op.f("ix_ingest_jobs_project_id"), "ingest_jobs", ["project_id"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_ingest_jobs_workspace_id"),
|
||||
"ingest_jobs",
|
||||
["workspace_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"classification_jobs",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("asset_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("classification", sa.String(length=50), nullable=False),
|
||||
sa.Column("confidence", sa.Float(), nullable=False),
|
||||
sa.Column("error_message", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_classification_jobs_asset_id"),
|
||||
"classification_jobs",
|
||||
["asset_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_classification_jobs_project_id"),
|
||||
"classification_jobs",
|
||||
["project_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_classification_jobs_workspace_id"),
|
||||
"classification_jobs",
|
||||
["workspace_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"generation_tasks",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("strategy_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("asset_library_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("voice_library_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("progress", sa.Float(), nullable=False),
|
||||
sa.Column("result_count", sa.Float(), nullable=False),
|
||||
sa.Column("error_message", sa.Text(), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_by_user_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_generation_tasks_asset_library_id"),
|
||||
"generation_tasks",
|
||||
["asset_library_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_generation_tasks_project_id"),
|
||||
"generation_tasks",
|
||||
["project_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(op.f("ix_generation_tasks_status"), "generation_tasks", ["status"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_generation_tasks_workspace_id"),
|
||||
"generation_tasks",
|
||||
["workspace_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"generated_videos",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("generation_task_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("file_url", sa.String(length=1000), nullable=False),
|
||||
sa.Column("file_size", sa.Float(), nullable=False),
|
||||
sa.Column("duration", sa.Float(), nullable=False),
|
||||
sa.Column("thumbnail_url", sa.String(length=1000), nullable=True),
|
||||
sa.Column("width", sa.Float(), nullable=False),
|
||||
sa.Column("height", sa.Float(), nullable=False),
|
||||
sa.Column("fps", sa.Float(), nullable=False),
|
||||
sa.Column("generated_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_generated_videos_generation_task_id"),
|
||||
"generated_videos",
|
||||
["generation_task_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_generated_videos_project_id"),
|
||||
"generated_videos",
|
||||
["project_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_generated_videos_workspace_id"),
|
||||
"generated_videos",
|
||||
["workspace_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"tasks",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("name", sa.String(length=200), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("priority", sa.String(length=20), nullable=False),
|
||||
sa.Column("parent_task_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("assignee_user_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("progress", sa.Float(), nullable=False),
|
||||
sa.Column("planned_start_date", sa.DateTime(), nullable=True),
|
||||
sa.Column("planned_end_date", sa.DateTime(), nullable=True),
|
||||
sa.Column("actual_start_date", sa.DateTime(), nullable=True),
|
||||
sa.Column("actual_end_date", sa.DateTime(), nullable=True),
|
||||
sa.Column("tags_json", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_tasks_parent_task_id"), "tasks", ["parent_task_id"], unique=False)
|
||||
op.create_index(op.f("ix_tasks_project_id"), "tasks", ["project_id"], unique=False)
|
||||
op.create_index(op.f("ix_tasks_status"), "tasks", ["status"], unique=False)
|
||||
op.create_index(op.f("ix_tasks_workspace_id"), "tasks", ["workspace_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"milestones",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("name", sa.String(length=200), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("target_date", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed", sa.Boolean(), nullable=False),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_milestones_project_id"), "milestones", ["project_id"], unique=False)
|
||||
op.create_index(op.f("ix_milestones_workspace_id"), "milestones", ["workspace_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"task_issues",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("task_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("title", sa.String(length=200), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("resolved", sa.Boolean(), nullable=False),
|
||||
sa.Column("resolved_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_by_user_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_task_issues_project_id"), "task_issues", ["project_id"], unique=False)
|
||||
op.create_index(op.f("ix_task_issues_task_id"), "task_issues", ["task_id"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_task_issues_workspace_id"),
|
||||
"task_issues",
|
||||
["workspace_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("task_issues")
|
||||
op.drop_table("milestones")
|
||||
op.drop_table("tasks")
|
||||
op.drop_table("generated_videos")
|
||||
op.drop_table("generation_tasks")
|
||||
op.drop_table("classification_jobs")
|
||||
op.drop_table("ingest_jobs")
|
||||
op.drop_table("assets")
|
||||
op.drop_table("asset_libraries")
|
||||
op.drop_table("projects")
|
||||
op.drop_table("users")
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Initial schema
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-06-15
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '001'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create projects table
|
||||
op.create_table(
|
||||
'projects',
|
||||
sa.Column('id', sa.String(32), nullable=False),
|
||||
sa.Column('workspace_id', sa.String(32), nullable=False),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False, server_default=''),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_projects_workspace_id'), 'projects', ['workspace_id'], unique=False)
|
||||
|
||||
# Create asset_libraries table
|
||||
op.create_table(
|
||||
'asset_libraries',
|
||||
sa.Column('id', sa.String(32), nullable=False),
|
||||
sa.Column('workspace_id', sa.String(32), nullable=False),
|
||||
sa.Column('project_id', sa.String(32), nullable=False),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('kind', sa.String(20), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_asset_libraries_workspace_id'), 'asset_libraries', ['workspace_id'], unique=False)
|
||||
op.create_index(op.f('ix_asset_libraries_project_id'), 'asset_libraries', ['project_id'], unique=False)
|
||||
|
||||
# Create assets table
|
||||
op.create_table(
|
||||
'assets',
|
||||
sa.Column('id', sa.String(32), nullable=False),
|
||||
sa.Column('workspace_id', sa.String(32), nullable=False),
|
||||
sa.Column('project_id', sa.String(32), nullable=False),
|
||||
sa.Column('library_id', sa.String(32), nullable=False),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('storage_key', sa.String(255), nullable=False),
|
||||
sa.Column('mime_type', sa.String(100), nullable=False),
|
||||
sa.Column('metadata_json', sa.Text(), nullable=False, server_default='{}'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_assets_workspace_id'), 'assets', ['workspace_id'], unique=False)
|
||||
op.create_index(op.f('ix_assets_project_id'), 'assets', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_assets_library_id'), 'assets', ['library_id'], unique=False)
|
||||
|
||||
# Create ingest_jobs table
|
||||
op.create_table(
|
||||
'ingest_jobs',
|
||||
sa.Column('id', sa.String(32), nullable=False),
|
||||
sa.Column('workspace_id', sa.String(32), nullable=False),
|
||||
sa.Column('project_id', sa.String(32), nullable=False),
|
||||
sa.Column('library_id', sa.String(32), nullable=False),
|
||||
sa.Column('storage_key', sa.String(255), nullable=False),
|
||||
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
|
||||
sa.Column('error_message', sa.Text(), nullable=False, server_default=''),
|
||||
sa.Column('result_asset_id', sa.String(32), nullable=False, server_default=''),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_ingest_jobs_workspace_id'), 'ingest_jobs', ['workspace_id'], unique=False)
|
||||
op.create_index(op.f('ix_ingest_jobs_project_id'), 'ingest_jobs', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_ingest_jobs_library_id'), 'ingest_jobs', ['library_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('ingest_jobs')
|
||||
op.drop_table('assets')
|
||||
op.drop_table('asset_libraries')
|
||||
op.drop_table('projects')
|
||||
@@ -1,91 +0,0 @@
|
||||
"""Add project management tables
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-06-16
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '002'
|
||||
down_revision: Union[str, None] = '001'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create tasks table
|
||||
op.create_table(
|
||||
'tasks',
|
||||
sa.Column('id', sa.String(32), nullable=False),
|
||||
sa.Column('project_id', sa.String(32), nullable=False),
|
||||
sa.Column('workspace_id', sa.String(32), nullable=False),
|
||||
sa.Column('name', sa.String(200), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False, server_default=''),
|
||||
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
|
||||
sa.Column('priority', sa.String(20), nullable=False, server_default='medium'),
|
||||
sa.Column('parent_task_id', sa.String(32), nullable=False, server_default=''),
|
||||
sa.Column('assignee_user_id', sa.String(32), nullable=False, server_default=''),
|
||||
sa.Column('progress', sa.Float(), nullable=False, server_default='0'),
|
||||
sa.Column('planned_start_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('planned_end_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('actual_start_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('actual_end_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('tags_json', sa.Text(), nullable=False, server_default='[]'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tasks_project_id'), 'tasks', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_tasks_workspace_id'), 'tasks', ['workspace_id'], unique=False)
|
||||
op.create_index(op.f('ix_tasks_parent_task_id'), 'tasks', ['parent_task_id'], unique=False)
|
||||
op.create_index(op.f('ix_tasks_status'), 'tasks', ['status'], unique=False)
|
||||
|
||||
# Create milestones table
|
||||
op.create_table(
|
||||
'milestones',
|
||||
sa.Column('id', sa.String(32), nullable=False),
|
||||
sa.Column('project_id', sa.String(32), nullable=False),
|
||||
sa.Column('workspace_id', sa.String(32), nullable=False),
|
||||
sa.Column('name', sa.String(200), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False, server_default=''),
|
||||
sa.Column('target_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('completed', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_milestones_project_id'), 'milestones', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_milestones_workspace_id'), 'milestones', ['workspace_id'], unique=False)
|
||||
|
||||
# Create task_issues table
|
||||
op.create_table(
|
||||
'task_issues',
|
||||
sa.Column('id', sa.String(32), nullable=False),
|
||||
sa.Column('task_id', sa.String(32), nullable=False),
|
||||
sa.Column('project_id', sa.String(32), nullable=False),
|
||||
sa.Column('workspace_id', sa.String(32), nullable=False),
|
||||
sa.Column('title', sa.String(200), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False, server_default=''),
|
||||
sa.Column('resolved', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('resolved_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_by_user_id', sa.String(32), nullable=False, server_default=''),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_task_issues_task_id'), 'task_issues', ['task_id'], unique=False)
|
||||
op.create_index(op.f('ix_task_issues_project_id'), 'task_issues', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_task_issues_workspace_id'), 'task_issues', ['workspace_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('task_issues')
|
||||
op.drop_table('milestones')
|
||||
op.drop_table('tasks')
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Add workspace core tables.
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-06-21
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "002"
|
||||
down_revision: Union[str, None] = "001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"workspaces",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("name", sa.String(length=100), nullable=False),
|
||||
sa.Column("owner_user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("subscription_plan", sa.String(length=20), nullable=False),
|
||||
sa.Column("subscription_status", sa.String(length=20), nullable=False),
|
||||
sa.Column("subscription_expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("max_projects", sa.Float(), nullable=False),
|
||||
sa.Column("max_storage_gb", sa.Float(), nullable=False),
|
||||
sa.Column("used_storage_gb", sa.Float(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_workspaces_owner_user_id"), "workspaces", ["owner_user_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"workspace_members",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("role", sa.String(length=20), nullable=False),
|
||||
sa.Column("invited_by", sa.String(length=36), nullable=True),
|
||||
sa.Column("joined_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("workspace_id", "user_id", name="uq_workspace_members_workspace_user"),
|
||||
)
|
||||
op.create_index(op.f("ix_workspace_members_user_id"), "workspace_members", ["user_id"], unique=False)
|
||||
op.create_index(op.f("ix_workspace_members_workspace_id"), "workspace_members", ["workspace_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"workspace_invitations",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("inviter_user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("invitee_email", sa.String(length=255), nullable=False),
|
||||
sa.Column("role", sa.String(length=20), nullable=False),
|
||||
sa.Column("invitation_token", sa.String(length=255), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("accepted_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workspace_invitations_invitation_token"),
|
||||
"workspace_invitations",
|
||||
["invitation_token"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workspace_invitations_invitee_email"), "workspace_invitations", ["invitee_email"], unique=False
|
||||
)
|
||||
op.create_index(op.f("ix_workspace_invitations_status"), "workspace_invitations", ["status"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_workspace_invitations_workspace_id"), "workspace_invitations", ["workspace_id"], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_workspace_invitations_workspace_id"), table_name="workspace_invitations")
|
||||
op.drop_index(op.f("ix_workspace_invitations_status"), table_name="workspace_invitations")
|
||||
op.drop_index(op.f("ix_workspace_invitations_invitee_email"), table_name="workspace_invitations")
|
||||
op.drop_index(op.f("ix_workspace_invitations_invitation_token"), table_name="workspace_invitations")
|
||||
op.drop_table("workspace_invitations")
|
||||
op.drop_index(op.f("ix_workspace_members_workspace_id"), table_name="workspace_members")
|
||||
op.drop_index(op.f("ix_workspace_members_user_id"), table_name="workspace_members")
|
||||
op.drop_table("workspace_members")
|
||||
op.drop_index(op.f("ix_workspaces_owner_user_id"), table_name="workspaces")
|
||||
op.drop_table("workspaces")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Add project titles.
|
||||
|
||||
Revision ID: 003
|
||||
Revises: 002
|
||||
Create Date: 2026-06-24
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "003"
|
||||
down_revision: Union[str, None] = "002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"project_titles",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("text", sa.String(length=200), nullable=False),
|
||||
sa.Column("category", sa.String(length=50), nullable=False, server_default="default"),
|
||||
sa.Column("usage_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("created_by_user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_project_titles_project_id"), "project_titles", ["project_id"], unique=False)
|
||||
op.create_index(op.f("ix_project_titles_workspace_id"), "project_titles", ["workspace_id"], unique=False)
|
||||
op.create_index(op.f("ix_project_titles_category"), "project_titles", ["category"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_project_titles_category"), table_name="project_titles")
|
||||
op.drop_index(op.f("ix_project_titles_workspace_id"), table_name="project_titles")
|
||||
op.drop_index(op.f("ix_project_titles_project_id"), table_name="project_titles")
|
||||
op.drop_table("project_titles")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Add project title favorite flag.
|
||||
|
||||
Revision ID: 004
|
||||
Revises: 003
|
||||
Create Date: 2026-06-24
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "004"
|
||||
down_revision: Union[str, None] = "003"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("project_titles", sa.Column("favorite", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
op.create_index(op.f("ix_project_titles_favorite"), "project_titles", ["favorite"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_project_titles_favorite"), table_name="project_titles")
|
||||
op.drop_column("project_titles", "favorite")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Add generated video management fields.
|
||||
|
||||
Revision ID: 005
|
||||
Revises: 004
|
||||
Create Date: 2026-06-24
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "005"
|
||||
down_revision: Union[str, None] = "004"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generated_videos", sa.Column("status", sa.String(length=20), nullable=False, server_default="completed")
|
||||
)
|
||||
op.add_column(
|
||||
"generated_videos",
|
||||
sa.Column("review_status", sa.String(length=20), nullable=False, server_default="pending_review"),
|
||||
)
|
||||
op.add_column("generated_videos", sa.Column("generation_params", sa.Text(), nullable=False, server_default="{}"))
|
||||
op.add_column("generated_videos", sa.Column("updated_at", sa.DateTime(), nullable=True))
|
||||
op.create_index(op.f("ix_generated_videos_status"), "generated_videos", ["status"], unique=False)
|
||||
op.create_index(op.f("ix_generated_videos_review_status"), "generated_videos", ["review_status"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_generated_videos_review_status"), table_name="generated_videos")
|
||||
op.drop_index(op.f("ix_generated_videos_status"), table_name="generated_videos")
|
||||
op.drop_column("generated_videos", "updated_at")
|
||||
op.drop_column("generated_videos", "generation_params")
|
||||
op.drop_column("generated_videos", "review_status")
|
||||
op.drop_column("generated_videos", "status")
|
||||
@@ -0,0 +1,59 @@
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "006"
|
||||
down_revision = "005"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"edit_templates",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("workspace_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("project_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("name", sa.String(120), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("target_duration", sa.Float(), nullable=False, server_default="30"),
|
||||
sa.Column("clip_count", sa.Integer(), nullable=False, server_default="3"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default=""),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_table(
|
||||
"edit_plans",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("workspace_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("project_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("template_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("asset_library_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("title_id", sa.String(32), nullable=False, server_default=""),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="draft", index=True),
|
||||
sa.Column("summary", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default=""),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_table(
|
||||
"edit_plan_clips",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("edit_plan_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("asset_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("start_time", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("duration", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("reason", sa.Text(), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column("generation_tasks", sa.Column("edit_plan_id", sa.String(32), nullable=False, server_default=""))
|
||||
op.create_index("ix_generation_tasks_edit_plan_id", "generation_tasks", ["edit_plan_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_generation_tasks_edit_plan_id", table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "edit_plan_id")
|
||||
op.drop_table("edit_plan_clips")
|
||||
op.drop_table("edit_plans")
|
||||
op.drop_table("edit_templates")
|
||||
@@ -1,23 +1,53 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
|
||||
from app.api.routes.asset_libraries import router as asset_libraries_router
|
||||
from app.api.routes.assets import router as assets_router
|
||||
from app.api.routes.auth import router as auth_router
|
||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||
from app.api.routes.edit_plans import router as edit_plans_router
|
||||
from app.api.routes.generated_videos import router as generated_videos_router
|
||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||
from app.api.routes.health import router as health_check_router
|
||||
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||
from app.api.routes.project_management import router as project_management_router
|
||||
from app.api.routes.project_titles import router as project_titles_router
|
||||
from app.api.routes.projects import router as projects_router
|
||||
from app.api.routes.task_center import router as task_center_router
|
||||
from app.api.routes.upload import router as upload_router
|
||||
from app.api.routes.workspaces import router as workspaces_router
|
||||
from fastapi import APIRouter
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
health_router = APIRouter()
|
||||
health_router.include_router(health_check_router)
|
||||
|
||||
api_router.include_router(
|
||||
auth_router,
|
||||
tags=["认证"],
|
||||
)
|
||||
api_router.include_router(
|
||||
workspaces_router,
|
||||
tags=["工作空间"],
|
||||
)
|
||||
api_router.include_router(
|
||||
projects_router,
|
||||
prefix="/projects",
|
||||
tags=["项目管理"],
|
||||
)
|
||||
api_router.include_router(
|
||||
project_titles_router,
|
||||
tags=["标题库"],
|
||||
)
|
||||
api_router.include_router(
|
||||
edit_plans_router,
|
||||
)
|
||||
api_router.include_router(
|
||||
task_center_router,
|
||||
tags=["任务中心"],
|
||||
)
|
||||
api_router.include_router(
|
||||
asset_diagnosis_router,
|
||||
tags=["素材诊断"],
|
||||
)
|
||||
api_router.include_router(
|
||||
asset_libraries_router,
|
||||
prefix="/asset-libraries",
|
||||
@@ -43,6 +73,16 @@ api_router.include_router(
|
||||
prefix="/upload",
|
||||
tags=["文件上传"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generation_tasks_router,
|
||||
prefix="/generation",
|
||||
tags=["生成任务"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generated_videos_router,
|
||||
prefix="/generated-videos",
|
||||
tags=["成片管理"],
|
||||
)
|
||||
api_router.include_router(
|
||||
project_management_router,
|
||||
prefix="/project-management",
|
||||
|
||||
@@ -1,53 +1,17 @@
|
||||
from fastapi import APIRouter
|
||||
"""API route package.
|
||||
|
||||
from app.api.routes.asset_libraries import router as asset_libraries_router
|
||||
from app.api.routes.assets import router as assets_router
|
||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||
from app.api.routes.health import router as health_router
|
||||
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||
from app.api.routes.project_management import router as project_management_router
|
||||
from app.api.routes.projects import router as projects_router
|
||||
from app.api.routes.upload import router as upload_router
|
||||
The canonical aggregated router lives in `app.api.router`. This package must not
|
||||
import route modules at package-import time, otherwise importing any sub-route can
|
||||
trigger circular imports and optional infrastructure dependencies.
|
||||
"""
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
health_router = APIRouter()
|
||||
|
||||
health_router.include_router(health_router := health_router)
|
||||
def __getattr__(name: str):
|
||||
if name in {"api_router", "health_router"}:
|
||||
from app.api.router import api_router, health_router
|
||||
|
||||
return {"api_router": api_router, "health_router": health_router}[name]
|
||||
raise AttributeError(name)
|
||||
|
||||
api_router.include_router(
|
||||
projects_router,
|
||||
prefix="/projects",
|
||||
tags=["项目管理"],
|
||||
)
|
||||
api_router.include_router(
|
||||
asset_libraries_router,
|
||||
prefix="/asset-libraries",
|
||||
tags=["素材库管理"],
|
||||
)
|
||||
api_router.include_router(
|
||||
assets_router,
|
||||
prefix="/assets",
|
||||
tags=["素材资产"],
|
||||
)
|
||||
api_router.include_router(
|
||||
ingest_jobs_router,
|
||||
prefix="/ingest-jobs",
|
||||
tags=["导入任务"],
|
||||
)
|
||||
api_router.include_router(
|
||||
classification_jobs_router,
|
||||
prefix="/classification-jobs",
|
||||
tags=["分类任务"],
|
||||
)
|
||||
api_router.include_router(
|
||||
upload_router,
|
||||
prefix="/upload",
|
||||
tags=["文件上传"],
|
||||
)
|
||||
api_router.include_router(
|
||||
project_management_router,
|
||||
prefix="/project-management",
|
||||
tags=["项目推进管理"],
|
||||
)
|
||||
|
||||
__all__ = ["api_router", "health_router"]
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
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_diagnosis import AssetGapItem, AssetSmartViewItem, ProjectAssetDiagnosisResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.domain import Asset, AssetLibraryKind, AssetStatus
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _asset_kind(asset: Asset) -> str:
|
||||
if asset.mime_type.startswith("video"):
|
||||
return "video"
|
||||
if asset.mime_type.startswith("audio"):
|
||||
return "voice"
|
||||
if asset.mime_type.startswith("image"):
|
||||
return "image"
|
||||
return asset.mime_type.split("/", 1)[0]
|
||||
|
||||
|
||||
def _readiness_label(score: int) -> str:
|
||||
if score >= 80:
|
||||
return "素材充足"
|
||||
if score >= 60:
|
||||
return "基本可生成"
|
||||
if score >= 40:
|
||||
return "需要补素材"
|
||||
return "暂不建议生成"
|
||||
|
||||
|
||||
def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) -> ProjectAssetDiagnosisResponse:
|
||||
ready_assets = [asset for asset in assets if asset.status == AssetStatus.READY]
|
||||
video_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.VIDEO]
|
||||
image_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.IMAGE]
|
||||
voice_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.VOICE]
|
||||
problem_assets = [
|
||||
asset for asset in assets if asset.status in {AssetStatus.ERROR, AssetStatus.UPLOADING, AssetStatus.PROCESSING}
|
||||
]
|
||||
unclassified_assets = [
|
||||
asset for asset in ready_assets if asset.classification_status.value in {"pending", "failed"}
|
||||
]
|
||||
risky_assets = [
|
||||
asset
|
||||
for asset in ready_assets
|
||||
if (asset.quality_score is not None and asset.quality_score < 60)
|
||||
or asset.metadata.get("review_status") == "rejected"
|
||||
or asset.status == AssetStatus.ERROR
|
||||
]
|
||||
used_assets = [asset for asset in ready_assets if int(asset.metadata.get("generation_use_count") or 0) > 0]
|
||||
unused_assets = [asset for asset in ready_assets if int(asset.metadata.get("generation_use_count") or 0) == 0]
|
||||
pending_review_assets = [asset for asset in ready_assets if asset.metadata.get("review_status") == "pending_review"]
|
||||
total_duration = round(sum(float(asset.duration or 0) for asset in video_assets), 2)
|
||||
estimated_video_count = max(
|
||||
0, min(len(video_assets), int(total_duration // 5) if total_duration else len(video_assets))
|
||||
)
|
||||
|
||||
score = 20
|
||||
if video_assets:
|
||||
score += 30
|
||||
if len(video_assets) >= 3:
|
||||
score += 15
|
||||
if total_duration >= 15:
|
||||
score += 15
|
||||
if image_assets:
|
||||
score += 5
|
||||
if voice_assets:
|
||||
score += 5
|
||||
if not problem_assets:
|
||||
score += 10
|
||||
score = max(0, min(100, score - min(25, len(risky_assets) * 5)))
|
||||
|
||||
gaps: list[AssetGapItem] = []
|
||||
if not video_assets:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="missing_video",
|
||||
severity="critical",
|
||||
message="缺少可用于生成的视频素材",
|
||||
recommendation="至少上传 1 个已导入完成的视频素材;建议上传 3 个以上,生成效果更稳定。",
|
||||
)
|
||||
)
|
||||
elif len(video_assets) < 3:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="low_video_count",
|
||||
severity="warning",
|
||||
message="视频素材数量偏少",
|
||||
recommendation="建议补充到 3 个以上视频素材,方便生成更多候选成片。",
|
||||
)
|
||||
)
|
||||
if total_duration and total_duration < 15:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="short_video_duration",
|
||||
severity="warning",
|
||||
message="可用视频总时长偏短",
|
||||
recommendation="建议补充更多原始视频,至少达到 15 秒以上。",
|
||||
)
|
||||
)
|
||||
if not voice_assets:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="missing_voice",
|
||||
severity="info",
|
||||
message="暂未配置配音素材",
|
||||
recommendation="如果本项目需要口播/旁白,请上传配音素材;纯画面生成可暂时忽略。",
|
||||
)
|
||||
)
|
||||
if problem_assets:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="not_ready_assets",
|
||||
severity="warning",
|
||||
message=f"有 {len(problem_assets)} 个素材尚未 ready",
|
||||
recommendation="等待导入完成或删除失败素材后再生成。",
|
||||
)
|
||||
)
|
||||
if risky_assets:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="low_quality_assets",
|
||||
severity="warning",
|
||||
message=f"有 {len(risky_assets)} 个素材质量分偏低",
|
||||
recommendation="优先使用清晰、稳定、时长充足的视频素材。",
|
||||
)
|
||||
)
|
||||
|
||||
smart_views = [
|
||||
AssetSmartViewItem(
|
||||
key="recommended", label="推荐素材", count=len(video_assets), description="已导入完成、可参与生成的视频素材"
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="needs_attention",
|
||||
label="慎用素材",
|
||||
count=len(problem_assets) + len(risky_assets),
|
||||
description="导入未完成、失败或质量分偏低的素材",
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="high_risk", label="高风险素材", count=len(risky_assets), description="质量分偏低或复核拒绝的素材"
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="unclassified",
|
||||
label="未分类素材",
|
||||
count=len(unclassified_assets),
|
||||
description="尚未完成分类或分类失败的 ready 素材",
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="recent",
|
||||
label="最近上传",
|
||||
count=min(len(assets), 10),
|
||||
description="最近进入素材库的素材,可用于快速复核",
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="unused", label="未使用素材", count=len(unused_assets), description="尚未参与生成的 ready 素材"
|
||||
),
|
||||
AssetSmartViewItem(key="used", label="已使用素材", count=len(used_assets), description="已经参与过生成的素材"),
|
||||
AssetSmartViewItem(
|
||||
key="pending_review",
|
||||
label="待复核素材",
|
||||
count=len(pending_review_assets),
|
||||
description="生成后待人工复核的素材",
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="voice", label="配音素材", count=len(voice_assets), description="可用于后续配音/旁白工作流的素材"
|
||||
),
|
||||
]
|
||||
|
||||
return ProjectAssetDiagnosisResponse(
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
readiness_score=score,
|
||||
readiness_label=_readiness_label(score),
|
||||
total_assets=len(assets),
|
||||
ready_assets=len(ready_assets),
|
||||
video_assets=len(video_assets),
|
||||
image_assets=len(image_assets),
|
||||
voice_assets=len(voice_assets),
|
||||
total_duration_seconds=total_duration,
|
||||
estimated_video_count=estimated_video_count,
|
||||
used_assets=len(used_assets),
|
||||
unused_assets=len(unused_assets),
|
||||
pending_review_assets=len(pending_review_assets),
|
||||
smart_views=smart_views,
|
||||
gaps=gaps,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/asset-diagnosis", response_model=ProjectAssetDiagnosisResponse)
|
||||
def get_project_asset_diagnosis(
|
||||
project_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ProjectAssetDiagnosisResponse:
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
|
||||
|
||||
libraries = asset_library_repository.list_by_project(project_id)
|
||||
assets: list[Asset] = []
|
||||
for library in libraries:
|
||||
assets.extend(asset_repository.list_by_library(library.id))
|
||||
|
||||
return _build_diagnosis(project.workspace_id, project_id, assets)
|
||||
@@ -1,42 +1,79 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from typing import Any
|
||||
|
||||
from app.dependencies import get_asset_library_repository
|
||||
from app.schemas.asset_library import AssetLibraryResponse, CreateAssetLibraryRequest, ListAssetLibrariesResponse
|
||||
from packages.adapters.sqlalchemy_impl import SQLAlchemyAssetLibraryRepository
|
||||
from packages.application import CreateAssetLibraryCommand, CreateAssetLibraryUseCase, ListAssetLibrariesUseCase
|
||||
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.schemas.asset_library import (
|
||||
AssetLibraryResponse,
|
||||
CreateAssetLibraryRequest,
|
||||
ListAssetLibrariesResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from packages.application import (
|
||||
CreateAssetLibraryCommand,
|
||||
CreateAssetLibraryUseCase,
|
||||
GetProjectUseCase,
|
||||
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 _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,
|
||||
asset_count=item.asset_count,
|
||||
total_size=item.total_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ListAssetLibrariesResponse)
|
||||
def list_asset_libraries(
|
||||
project_id: str,
|
||||
kind: str | None = None,
|
||||
asset_library_repository: SQLAlchemyAssetLibraryRepository = Depends(get_asset_library_repository),
|
||||
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)
|
||||
use_case = ListAssetLibrariesUseCase(asset_library_repository)
|
||||
parsed_kind = AssetLibraryKind(kind) if kind else None
|
||||
items = use_case.execute(project_id, kind=parsed_kind)
|
||||
return ListAssetLibrariesResponse(
|
||||
items=[
|
||||
AssetLibraryResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
name=item.name,
|
||||
kind=item.kind.value,
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
)
|
||||
return ListAssetLibrariesResponse(items=[_to_asset_library_response(item) for item in items])
|
||||
|
||||
|
||||
@router.post("", response_model=AssetLibraryResponse)
|
||||
def create_asset_library(
|
||||
request: CreateAssetLibraryRequest,
|
||||
asset_library_repository: SQLAlchemyAssetLibraryRepository = Depends(get_asset_library_repository),
|
||||
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:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
_require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository)
|
||||
use_case = CreateAssetLibraryUseCase(asset_library_repository)
|
||||
item = use_case.execute(
|
||||
CreateAssetLibraryCommand(
|
||||
@@ -46,10 +83,4 @@ def create_asset_library(
|
||||
kind=AssetLibraryKind(request.kind),
|
||||
)
|
||||
)
|
||||
return AssetLibraryResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
name=item.name,
|
||||
kind=item.kind.value,
|
||||
)
|
||||
return _to_asset_library_response(item)
|
||||
|
||||
@@ -1,42 +1,110 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from typing import Any
|
||||
|
||||
from app.dependencies import get_asset_repository
|
||||
from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse
|
||||
from packages.adapters.sqlalchemy_impl import SQLAlchemyAssetRepository
|
||||
from packages.application import CreateAssetCommand, CreateAssetUseCase, ListAssetsUseCase
|
||||
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
|
||||
|
||||
from packages.application import (
|
||||
CreateAssetCommand,
|
||||
CreateAssetUseCase,
|
||||
ListAssetsUseCase,
|
||||
)
|
||||
from packages.domain import AssetStatus, ClassificationStatus
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
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,
|
||||
storage_key=item.storage_key,
|
||||
mime_type=item.mime_type,
|
||||
metadata=item.metadata,
|
||||
file_size=item.file_size,
|
||||
thumbnail_url=item.thumbnail_url,
|
||||
duration=item.duration,
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
fps=item.fps,
|
||||
codec=item.codec,
|
||||
status=item.status.value,
|
||||
classification_status=item.classification_status.value,
|
||||
quality_score=item.quality_score,
|
||||
uploaded_by_user_id=item.uploaded_by_user_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ListAssetsResponse)
|
||||
def list_assets(
|
||||
library_id: str,
|
||||
asset_repository: SQLAlchemyAssetRepository = Depends(get_asset_repository),
|
||||
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),
|
||||
) -> 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)
|
||||
use_case = ListAssetsUseCase(asset_repository)
|
||||
items = use_case.execute(library_id)
|
||||
return ListAssetsResponse(
|
||||
items=[
|
||||
AssetResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
library_id=item.library_id,
|
||||
name=item.name,
|
||||
storage_key=item.storage_key,
|
||||
mime_type=item.mime_type,
|
||||
metadata=item.metadata,
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
)
|
||||
return ListAssetsResponse(items=[_to_asset_response(item) for item in items])
|
||||
|
||||
|
||||
def _apply_asset_review_status(item, review_status: str):
|
||||
item.metadata = {
|
||||
**item.metadata,
|
||||
"review_status": review_status,
|
||||
}
|
||||
return item
|
||||
|
||||
|
||||
@router.patch("/{asset_id}/review", response_model=AssetResponse)
|
||||
def update_asset_review_status(
|
||||
asset_id: str,
|
||||
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),
|
||||
) -> 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)
|
||||
_apply_asset_review_status(item, request.review_status)
|
||||
updated = asset_repository.update(item)
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.post("", response_model=AssetResponse)
|
||||
def create_asset(
|
||||
request: CreateAssetRequest,
|
||||
asset_repository: SQLAlchemyAssetRepository = Depends(get_asset_repository),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found")
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found")
|
||||
|
||||
use_case = CreateAssetUseCase(asset_repository)
|
||||
item = use_case.execute(
|
||||
CreateAssetCommand(
|
||||
@@ -47,15 +115,17 @@ def create_asset(
|
||||
storage_key=request.storage_key,
|
||||
mime_type=request.mime_type,
|
||||
metadata=request.metadata,
|
||||
file_size=request.file_size,
|
||||
thumbnail_url=request.thumbnail_url,
|
||||
duration=request.duration,
|
||||
width=request.width,
|
||||
height=request.height,
|
||||
fps=request.fps,
|
||||
codec=request.codec,
|
||||
status=AssetStatus(request.status),
|
||||
classification_status=ClassificationStatus(request.classification_status),
|
||||
quality_score=request.quality_score,
|
||||
uploaded_by_user_id=authenticated_user.user.id,
|
||||
)
|
||||
)
|
||||
return AssetResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
library_id=item.library_id,
|
||||
name=item.name,
|
||||
storage_key=item.storage_key,
|
||||
mime_type=item.mime_type,
|
||||
metadata=item.metadata,
|
||||
)
|
||||
return _to_asset_response(item)
|
||||
|
||||
+161
-174
@@ -1,55 +1,57 @@
|
||||
"""
|
||||
认证 API 路由
|
||||
Canonical authentication API routes.
|
||||
|
||||
The route layer is intentionally thin: repository construction lives in
|
||||
app.dependencies and authentication behavior lives in application use cases.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import settings
|
||||
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from packages.application.auth import (
|
||||
RegisterUserUseCase,
|
||||
RegisterUserRequest,
|
||||
LoginUseCase,
|
||||
LoginRequest,
|
||||
LogoutUseCase,
|
||||
LogoutRequest,
|
||||
VerifyEmailUseCase,
|
||||
VerifyEmailRequest,
|
||||
from packages.adapters.redis import NoopSessionStore
|
||||
from packages.adapters.smtp import NoopEmailService
|
||||
from packages.application.auth.login_use_case import LoginRequest as LoginUseCaseRequest
|
||||
from packages.application.auth.login_use_case import LoginUseCase
|
||||
from packages.application.auth.password_reset_use_case import RequestPasswordResetRequest as PasswordResetUseCaseRequest
|
||||
from packages.application.auth.password_reset_use_case import (
|
||||
RequestPasswordResetUseCase,
|
||||
RequestPasswordResetRequest,
|
||||
ResetPasswordUseCase,
|
||||
ResetPasswordRequest,
|
||||
ResetPasswordUseCase,
|
||||
)
|
||||
from packages.domain.entities import User
|
||||
from apps.api.app.dependencies import get_container
|
||||
from apps.api.app.middleware.auth import get_current_user
|
||||
from packages.application.auth.register_user_use_case import RegisterUserRequest as RegisterUseCaseRequest
|
||||
from packages.application.auth.register_user_use_case import RegisterUserUseCase, VerifyEmailRequest, VerifyEmailUseCase
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
# ==================== Request/Response Models ====================
|
||||
|
||||
class RegisterRequestModel(BaseModel):
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
username: str
|
||||
display_name: str
|
||||
display_name: Optional[str] = None
|
||||
|
||||
|
||||
class RegisterResponseModel(BaseModel):
|
||||
class RegisterResponse(BaseModel):
|
||||
user_id: str
|
||||
email: str
|
||||
username: str
|
||||
display_name: str
|
||||
email_verification_sent: bool
|
||||
message: str
|
||||
|
||||
|
||||
class LoginRequestModel(BaseModel):
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponseModel(BaseModel):
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user_id: str
|
||||
email: str
|
||||
@@ -58,6 +60,14 @@ class LoginResponseModel(BaseModel):
|
||||
expires_in: int
|
||||
|
||||
|
||||
class CurrentUserResponse(BaseModel):
|
||||
user_id: str
|
||||
email: str
|
||||
username: str
|
||||
display_name: str
|
||||
email_verified: bool
|
||||
|
||||
|
||||
class PasswordResetRequestModel(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
@@ -67,74 +77,62 @@ class ResetPasswordModel(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
# ==================== API Endpoints ====================
|
||||
class VerifyEmailRequestModel(BaseModel):
|
||||
token: str
|
||||
|
||||
@router.post("/register", response_model=RegisterResponseModel, status_code=status.HTTP_201_CREATED)
|
||||
async def register(request: RegisterRequestModel):
|
||||
"""
|
||||
用户注册
|
||||
|
||||
- 邮箱必须唯一
|
||||
- 用户名必须唯一
|
||||
- 密码至少 8 位,包含大小写字母和数字
|
||||
- 注册后发送邮箱验证邮件
|
||||
"""
|
||||
container = get_container()
|
||||
use_case = container.get_register_user_use_case()
|
||||
|
||||
req = RegisterUserRequest(
|
||||
email=request.email,
|
||||
password=request.password,
|
||||
username=request.username,
|
||||
display_name=request.display_name,
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
request: RegisterRequest,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
email_service=Depends(get_auth_email_service),
|
||||
):
|
||||
use_case = RegisterUserUseCase(
|
||||
user_repository=user_repository,
|
||||
base_url="http://localhost:3000",
|
||||
email_service=email_service,
|
||||
)
|
||||
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
response, error = use_case.execute(
|
||||
RegisterUseCaseRequest(
|
||||
email=request.email,
|
||||
password=request.password,
|
||||
username=request.username,
|
||||
display_name=request.display_name or request.username,
|
||||
)
|
||||
|
||||
return RegisterResponseModel(
|
||||
)
|
||||
if error or response is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=_translate_auth_error(error))
|
||||
|
||||
return RegisterResponse(
|
||||
user_id=response.user_id,
|
||||
email=response.email,
|
||||
username=response.username,
|
||||
display_name=response.display_name,
|
||||
email_verification_sent=response.email_verification_sent,
|
||||
message="注册成功!",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponseModel)
|
||||
async def login(request: LoginRequestModel):
|
||||
"""
|
||||
用户登录
|
||||
|
||||
- 使用邮箱和密码登录
|
||||
- 返回 access_token 和 refresh_token
|
||||
- access_token 有效期 30 分钟
|
||||
- refresh_token 有效期 30 天
|
||||
"""
|
||||
container = get_container()
|
||||
use_case = container.get_login_use_case()
|
||||
|
||||
req = LoginRequest(
|
||||
email=request.email,
|
||||
password=request.password,
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
request: LoginRequest,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
session_store=Depends(get_auth_session_store),
|
||||
):
|
||||
use_case = LoginUseCase(
|
||||
user_repository=user_repository,
|
||||
session_store=session_store,
|
||||
jwt_secret_key=settings.JWT_SECRET_KEY,
|
||||
)
|
||||
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
return LoginResponseModel(
|
||||
response, error = use_case.execute(LoginUseCaseRequest(email=request.email, password=request.password))
|
||||
if error or response is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
|
||||
|
||||
return LoginResponse(
|
||||
access_token=response.access_token,
|
||||
refresh_token=response.refresh_token,
|
||||
user_id=response.user_id,
|
||||
email=response.email,
|
||||
username=response.username,
|
||||
@@ -143,101 +141,90 @@ async def login(request: LoginRequestModel):
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
logout_all_devices: bool = False,
|
||||
current_user: User = Depends(get_current_user),
|
||||
def _verify_email_token(token: str, user_repository: UserRepository) -> MessageResponse:
|
||||
success, error = VerifyEmailUseCase(user_repository=user_repository).execute(VerifyEmailRequest(token=token))
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "邮箱验证失败")
|
||||
|
||||
return MessageResponse(message="邮箱验证成功")
|
||||
|
||||
|
||||
@router.get("/verify-email", response_model=MessageResponse)
|
||||
async def verify_email(
|
||||
token: str,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
):
|
||||
"""
|
||||
用户登出
|
||||
|
||||
- 默认只登出当前设备
|
||||
- 设置 logout_all_devices=true 可登出所有设备
|
||||
"""
|
||||
container = get_container()
|
||||
use_case = container.get_logout_use_case()
|
||||
|
||||
req = LogoutRequest(
|
||||
user_id=current_user.id,
|
||||
session_id=None, # TODO: 从 token 中获取 session_id
|
||||
logout_all_devices=logout_all_devices,
|
||||
return _verify_email_token(token, user_repository)
|
||||
|
||||
|
||||
@router.post("/verify-email", response_model=MessageResponse)
|
||||
async def verify_email_post(
|
||||
request: VerifyEmailRequestModel,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
):
|
||||
return _verify_email_token(request.token, user_repository)
|
||||
|
||||
|
||||
@router.post("/password/forgot", response_model=MessageResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||
@router.post("/forgot-password", response_model=MessageResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||
async def forgot_password(
|
||||
request: PasswordResetRequestModel,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
email_service=Depends(get_auth_email_service),
|
||||
):
|
||||
success, error = RequestPasswordResetUseCase(
|
||||
user_repository=user_repository,
|
||||
base_url="http://localhost:3000",
|
||||
email_service=email_service,
|
||||
).execute(PasswordResetUseCaseRequest(email=request.email))
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "密码重置请求失败")
|
||||
|
||||
return MessageResponse(message="如果账户存在,密码重置邮件已发送")
|
||||
|
||||
|
||||
@router.post("/password/reset", response_model=MessageResponse)
|
||||
@router.post("/reset-password", response_model=MessageResponse)
|
||||
async def reset_password(
|
||||
request: ResetPasswordModel,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
):
|
||||
success, error = ResetPasswordUseCase(user_repository=user_repository).execute(
|
||||
ResetPasswordRequest(token=request.token, new_password=request.new_password)
|
||||
)
|
||||
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "密码重置失败")
|
||||
|
||||
return MessageResponse(message="密码重置成功")
|
||||
|
||||
|
||||
@router.get("/verify-email")
|
||||
async def verify_email(token: str):
|
||||
"""
|
||||
邮箱验证
|
||||
|
||||
- 通过邮件中的链接访问此接口
|
||||
- 验证成功后标记邮箱为已验证
|
||||
"""
|
||||
container = get_container()
|
||||
use_case = container.get_verify_email_use_case()
|
||||
|
||||
req = VerifyEmailRequest(token=token)
|
||||
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
return {"message": "Email verified successfully"}
|
||||
|
||||
|
||||
@router.post("/password/forgot", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def forgot_password(request: PasswordResetRequestModel):
|
||||
"""
|
||||
请求密码重置
|
||||
|
||||
- 发送密码重置邮件
|
||||
- 邮件中包含重置链接(有效期 1 小时)
|
||||
- 即使邮箱不存在也返回成功(安全考虑)
|
||||
"""
|
||||
container = get_container()
|
||||
use_case = container.get_request_password_reset_use_case()
|
||||
|
||||
req = RequestPasswordResetRequest(email=request.email)
|
||||
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
# 不论成功失败都返回 202(安全考虑)
|
||||
return {"message": "Password reset email sent if account exists"}
|
||||
|
||||
|
||||
@router.post("/password/reset", status_code=status.HTTP_200_OK)
|
||||
async def reset_password(request: ResetPasswordModel):
|
||||
"""
|
||||
重置密码
|
||||
|
||||
- 使用邮件中的 token 重置密码
|
||||
- 新密码必须符合密码强度要求
|
||||
"""
|
||||
container = get_container()
|
||||
use_case = container.get_reset_password_use_case()
|
||||
|
||||
req = ResetPasswordRequest(
|
||||
token=request.token,
|
||||
new_password=request.new_password,
|
||||
@router.get("/me", response_model=CurrentUserResponse)
|
||||
async def get_current_user_info(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
user = authenticated_user.user
|
||||
return CurrentUserResponse(
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
email_verified=user.email_verified,
|
||||
)
|
||||
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
return {"message": "Password reset successfully"}
|
||||
|
||||
|
||||
class _NoopSessionStore(NoopSessionStore):
|
||||
pass
|
||||
|
||||
|
||||
class _NoopEmailService(NoopEmailService):
|
||||
pass
|
||||
|
||||
|
||||
def _translate_auth_error(error: str | None) -> str:
|
||||
translations = {
|
||||
"Email already registered": "邮箱已被注册",
|
||||
"Username already taken": "用户名已被使用",
|
||||
"Username is required": "用户名不能为空",
|
||||
"Display name is required": "显示名称不能为空",
|
||||
}
|
||||
return translations.get(error or "", error or "注册失败")
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Any
|
||||
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_classification_job_repository
|
||||
from app.schemas.classification_job import ClassificationJobResponse, SubmitClassificationJobRequest
|
||||
from packages.adapters.sqlalchemy_impl import SQLAlchemyClassificationJobRepository
|
||||
from packages.application import SubmitClassificationJobCommand, SubmitClassificationJobUseCase
|
||||
from app.schemas.classification_job import (
|
||||
ClassificationJobResponse,
|
||||
SubmitClassificationJobRequest,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
SubmitClassificationJobCommand,
|
||||
SubmitClassificationJobUseCase,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -14,7 +20,7 @@ router = APIRouter()
|
||||
@router.get("/{job_id}", response_model=ClassificationJobResponse)
|
||||
def get_classification_job(
|
||||
job_id: str,
|
||||
classification_job_repository: SQLAlchemyClassificationJobRepository = Depends(get_classification_job_repository),
|
||||
classification_job_repository: Any = Depends(get_classification_job_repository),
|
||||
) -> ClassificationJobResponse:
|
||||
job = classification_job_repository.get(job_id)
|
||||
if job is None:
|
||||
@@ -34,7 +40,7 @@ def get_classification_job(
|
||||
@router.post("", response_model=ClassificationJobResponse)
|
||||
def submit_classification_job(
|
||||
request: SubmitClassificationJobRequest,
|
||||
classification_job_repository: SQLAlchemyClassificationJobRepository = Depends(get_classification_job_repository),
|
||||
classification_job_repository: Any = Depends(get_classification_job_repository),
|
||||
) -> ClassificationJobResponse:
|
||||
use_case = SubmitClassificationJobUseCase(classification_job_repository)
|
||||
job = use_case.execute(
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from app.api.routes.permissions import require_workspace_member
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_project_repository,
|
||||
get_workspace_member_repository,
|
||||
)
|
||||
from app.schemas.edit_plan import CreateEditPlanRequest, EditPlanClipResponse, EditPlanResponse, EditTemplateResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel, EditPlanModel, EditTemplateModel
|
||||
from packages.domain import AssetStatus
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/edit-plans", tags=["剪辑计划"])
|
||||
|
||||
|
||||
def _ensure_project(project_id: str, workspace_id: str, user: AuthenticatedUser, project_repository, member_repository):
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None or project.workspace_id != workspace_id:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
require_workspace_member(workspace_id, user, member_repository)
|
||||
return project
|
||||
|
||||
|
||||
def _default_template(session: Session, workspace_id: str, project_id: str, user_id: str) -> EditTemplateModel:
|
||||
template = (
|
||||
session.query(EditTemplateModel)
|
||||
.filter(
|
||||
EditTemplateModel.project_id == project_id,
|
||||
EditTemplateModel.is_active.is_(True),
|
||||
)
|
||||
.order_by(EditTemplateModel.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
if template is not None:
|
||||
return template
|
||||
template = EditTemplateModel(
|
||||
id=uuid4().hex,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
name="基础节奏模板",
|
||||
description="自动选择可用视频素材,按上传顺序生成三段式剪辑计划。",
|
||||
target_duration=30,
|
||||
clip_count=3,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
session.add(template)
|
||||
session.commit()
|
||||
return template
|
||||
|
||||
|
||||
def _to_template_response(template: EditTemplateModel) -> EditTemplateResponse:
|
||||
return EditTemplateResponse(
|
||||
id=template.id,
|
||||
workspace_id=template.workspace_id,
|
||||
project_id=template.project_id,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
target_duration=float(template.target_duration or 0),
|
||||
clip_count=int(template.clip_count or 0),
|
||||
is_active=bool(template.is_active),
|
||||
created_at=template.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _to_plan_response(
|
||||
plan: EditPlanModel, clips: list[EditPlanClipModel], asset_names: dict[str, str]
|
||||
) -> EditPlanResponse:
|
||||
return EditPlanResponse(
|
||||
id=plan.id,
|
||||
workspace_id=plan.workspace_id,
|
||||
project_id=plan.project_id,
|
||||
template_id=plan.template_id,
|
||||
asset_library_id=plan.asset_library_id,
|
||||
title_id=plan.title_id,
|
||||
status=plan.status,
|
||||
summary=plan.summary,
|
||||
clips=[
|
||||
EditPlanClipResponse(
|
||||
id=clip.id,
|
||||
asset_id=clip.asset_id,
|
||||
asset_name=asset_names.get(clip.asset_id, clip.asset_id),
|
||||
sequence=clip.sequence,
|
||||
start_time=float(clip.start_time or 0),
|
||||
duration=float(clip.duration or 0),
|
||||
reason=clip.reason,
|
||||
)
|
||||
for clip in clips
|
||||
],
|
||||
created_at=plan.created_at,
|
||||
updated_at=plan.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/templates", response_model=list[EditTemplateResponse])
|
||||
def list_edit_templates(
|
||||
project_id: str,
|
||||
workspace_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository=Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> list[EditTemplateResponse]:
|
||||
_ensure_project(project_id, workspace_id, authenticated_user, project_repository, workspace_member_repository)
|
||||
template = _default_template(session, workspace_id, project_id, authenticated_user.user.id)
|
||||
templates = (
|
||||
session.query(EditTemplateModel)
|
||||
.filter(EditTemplateModel.project_id == project_id, EditTemplateModel.is_active.is_(True))
|
||||
.all()
|
||||
)
|
||||
return [_to_template_response(item) for item in templates or [template]]
|
||||
|
||||
|
||||
@router.post("", response_model=EditPlanResponse)
|
||||
def create_edit_plan(
|
||||
project_id: str,
|
||||
request: CreateEditPlanRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository=Depends(get_project_repository),
|
||||
asset_repository=Depends(get_asset_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> EditPlanResponse:
|
||||
_ensure_project(
|
||||
project_id, request.workspace_id, authenticated_user, project_repository, workspace_member_repository
|
||||
)
|
||||
template = (
|
||||
session.query(EditTemplateModel).filter(EditTemplateModel.id == request.template_id).first()
|
||||
if request.template_id
|
||||
else None
|
||||
)
|
||||
if template is None:
|
||||
template = _default_template(session, request.workspace_id, project_id, authenticated_user.user.id)
|
||||
assets = [
|
||||
asset
|
||||
for asset in asset_repository.list_by_library(request.asset_library_id)
|
||||
if asset.status == AssetStatus.READY and asset.mime_type.startswith("video/")
|
||||
]
|
||||
if not assets:
|
||||
raise HTTPException(status_code=422, detail="素材库暂无可用于剪辑计划的视频素材")
|
||||
selected = sorted(assets, key=lambda asset: (-(asset.quality_score or 0), asset.created_at))[
|
||||
: max(1, int(template.clip_count or 3))
|
||||
]
|
||||
plan = EditPlanModel(
|
||||
id=uuid4().hex,
|
||||
workspace_id=request.workspace_id,
|
||||
project_id=project_id,
|
||||
template_id=template.id,
|
||||
asset_library_id=request.asset_library_id,
|
||||
title_id=request.title_id,
|
||||
status="draft",
|
||||
summary=f"按《{template.name}》自动选择 {len(selected)} 段素材,预计生成约 {int(template.target_duration or 30)} 秒成片。",
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(plan)
|
||||
clips: list[EditPlanClipModel] = []
|
||||
clip_duration = max(1, float(template.target_duration or 30) / len(selected))
|
||||
for index, asset in enumerate(selected, start=1):
|
||||
clip = EditPlanClipModel(
|
||||
id=uuid4().hex,
|
||||
edit_plan_id=plan.id,
|
||||
asset_id=asset.id,
|
||||
sequence=index,
|
||||
start_time=0,
|
||||
duration=min(float(asset.duration or clip_duration), clip_duration),
|
||||
reason="优先选择已就绪、质量分较高的视频素材。",
|
||||
)
|
||||
session.add(clip)
|
||||
clips.append(clip)
|
||||
session.commit()
|
||||
return _to_plan_response(plan, clips, {asset.id: asset.name for asset in selected})
|
||||
|
||||
|
||||
@router.get("/{plan_id}", response_model=EditPlanResponse)
|
||||
def get_edit_plan(
|
||||
project_id: str,
|
||||
plan_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository=Depends(get_project_repository),
|
||||
asset_repository=Depends(get_asset_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> EditPlanResponse:
|
||||
plan = (
|
||||
session.query(EditPlanModel).filter(EditPlanModel.id == plan_id, EditPlanModel.project_id == project_id).first()
|
||||
)
|
||||
if plan is None:
|
||||
raise HTTPException(status_code=404, detail="Edit plan not found")
|
||||
_ensure_project(project_id, plan.workspace_id, authenticated_user, project_repository, workspace_member_repository)
|
||||
clips = (
|
||||
session.query(EditPlanClipModel)
|
||||
.filter(EditPlanClipModel.edit_plan_id == plan.id)
|
||||
.order_by(EditPlanClipModel.sequence.asc())
|
||||
.all()
|
||||
)
|
||||
assets = asset_repository.list_by_library(plan.asset_library_id)
|
||||
return _to_plan_response(plan, clips, {asset.id: asset.name for asset in assets})
|
||||
@@ -0,0 +1,111 @@
|
||||
from typing import Any
|
||||
|
||||
from app.api.routes.permissions import require_workspace_member
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import get_generated_video_repository, get_project_repository, get_workspace_member_repository
|
||||
from app.schemas.generated_video import (
|
||||
GeneratedVideoDownloadUrlResponse,
|
||||
GeneratedVideoResponse,
|
||||
ListGeneratedVideosResponse,
|
||||
UpdateGeneratedVideoReviewRequest,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
GetGeneratedVideoDownloadUrlUseCase,
|
||||
GetGeneratedVideoUseCase,
|
||||
ListGeneratedVideosUseCase,
|
||||
)
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
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,
|
||||
file_url=item.file_url,
|
||||
file_size=item.file_size,
|
||||
duration=item.duration,
|
||||
thumbnail_url=item.thumbnail_url,
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
fps=item.fps,
|
||||
status=item.status,
|
||||
review_status=item.review_status,
|
||||
generation_params=item.generation_params,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ListGeneratedVideosResponse)
|
||||
def list_generated_videos(
|
||||
project_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ListGeneratedVideosResponse:
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
|
||||
use_case = ListGeneratedVideosUseCase(generated_video_repository)
|
||||
items = use_case.execute(project_id)
|
||||
return ListGeneratedVideosResponse(items=[_to_generated_video_response(item) for item in items])
|
||||
|
||||
|
||||
@router.get("/{video_id}", response_model=GeneratedVideoResponse)
|
||||
def get_generated_video(
|
||||
video_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> GeneratedVideoResponse:
|
||||
use_case = GetGeneratedVideoUseCase(generated_video_repository)
|
||||
item = use_case.execute(video_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
||||
require_workspace_member(item.workspace_id, authenticated_user, workspace_member_repository)
|
||||
return _to_generated_video_response(item)
|
||||
|
||||
|
||||
@router.patch("/{video_id}/review", response_model=GeneratedVideoResponse)
|
||||
def update_generated_video_review_status(
|
||||
video_id: str,
|
||||
request: UpdateGeneratedVideoReviewRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> GeneratedVideoResponse:
|
||||
video = generated_video_repository.get(video_id)
|
||||
if video is None:
|
||||
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
||||
require_workspace_member(video.workspace_id, authenticated_user, workspace_member_repository)
|
||||
video.review_status = request.review_status
|
||||
updated = generated_video_repository.update(video)
|
||||
return _to_generated_video_response(updated)
|
||||
|
||||
|
||||
@router.get("/{video_id}/download-url", response_model=GeneratedVideoDownloadUrlResponse)
|
||||
def get_generated_video_download_url(
|
||||
video_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> GeneratedVideoDownloadUrlResponse:
|
||||
video = generated_video_repository.get(video_id)
|
||||
if video is None:
|
||||
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
||||
require_workspace_member(video.workspace_id, authenticated_user, workspace_member_repository)
|
||||
use_case = GetGeneratedVideoDownloadUrlUseCase(generated_video_repository)
|
||||
file_url = use_case.execute(video_id)
|
||||
if file_url is None:
|
||||
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
||||
download_url = storage_service.get_download_url(file_url)
|
||||
return GeneratedVideoDownloadUrlResponse(video_id=video_id, download_url=download_url)
|
||||
@@ -0,0 +1,161 @@
|
||||
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 (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
get_project_repository,
|
||||
get_project_title_repository,
|
||||
get_workspace_member_repository,
|
||||
)
|
||||
from app.schemas.generated_video import (
|
||||
GeneratedVideoResponse,
|
||||
ListGeneratedVideosResponse,
|
||||
)
|
||||
from app.schemas.generation_task import (
|
||||
CreateGenerationTaskRequest,
|
||||
GenerationTaskResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
GetGenerationTaskUseCase,
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
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,
|
||||
progress=task.progress,
|
||||
result_count=task.result_count,
|
||||
error_message=task.error_message,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
file_url=item.file_url,
|
||||
file_size=item.file_size,
|
||||
duration=item.duration,
|
||||
thumbnail_url=item.thumbnail_url,
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
fps=item.fps,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_library_has_ready_video_assets(assets) -> None:
|
||||
ready_video_assets = [
|
||||
asset for asset in assets if asset.status.value == "ready" and asset.mime_type.startswith("video")
|
||||
]
|
||||
if not ready_video_assets:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="当前素材库没有 ready 状态的视频素材,请先上传并等待导入完成后再生成。",
|
||||
)
|
||||
|
||||
|
||||
def _select_title_id(project_title_repository: Any, project_id: str) -> str:
|
||||
active_titles = project_title_repository.list_by_project(project_id, active_only=True)
|
||||
if not active_titles:
|
||||
return ""
|
||||
selected = sorted(
|
||||
active_titles,
|
||||
key=lambda title: (
|
||||
0 if getattr(title, "favorite", False) else 1,
|
||||
int(title.usage_count or 0),
|
||||
title.created_at,
|
||||
),
|
||||
)[0]
|
||||
return selected.id
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=GenerationTaskResponse)
|
||||
def create_generation_task(
|
||||
request: CreateGenerationTaskRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found")
|
||||
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:
|
||||
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,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
return _to_generation_task_response(task)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}", response_model=GenerationTaskResponse)
|
||||
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),
|
||||
) -> 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)
|
||||
return _to_generation_task_response(task)
|
||||
|
||||
|
||||
@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),
|
||||
) -> 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)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
|
||||
items = use_case.execute(task_id)
|
||||
return ListGeneratedVideosResponse(items=[_to_generated_video_response(item) for item in items])
|
||||
@@ -1,12 +1,11 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
import psycopg2
|
||||
import redis
|
||||
from app.config import settings
|
||||
from fastapi import APIRouter, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import settings
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(tags=["Health"])
|
||||
|
||||
@@ -56,16 +55,28 @@ async def startup_check():
|
||||
|
||||
async def _check_database() -> dict:
|
||||
if settings.USE_IN_MEMORY_DB:
|
||||
return {"status": "healthy", "type": "in_memory", "message": "Using in-memory database"}
|
||||
return {
|
||||
"status": "healthy",
|
||||
"type": "in_memory",
|
||||
"message": "Using in-memory database",
|
||||
}
|
||||
try:
|
||||
conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1")
|
||||
cur.fetchone()
|
||||
conn.close()
|
||||
return {"status": "healthy", "type": "postgresql", "message": "Database connection successful"}
|
||||
return {
|
||||
"status": "healthy",
|
||||
"type": "postgresql",
|
||||
"message": "Database connection successful",
|
||||
}
|
||||
except Exception as error:
|
||||
return {"status": "unhealthy", "type": "postgresql", "message": f"Database connection failed: {error}"}
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"type": "postgresql",
|
||||
"message": f"Database connection failed: {error}",
|
||||
}
|
||||
|
||||
|
||||
async def _check_redis() -> dict:
|
||||
@@ -73,23 +84,32 @@ async def _check_redis() -> dict:
|
||||
client = redis.from_url(settings.REDIS_URL, socket_connect_timeout=3)
|
||||
client.ping()
|
||||
client.close()
|
||||
return {"status": "healthy", "type": "redis", "message": "Redis connection successful"}
|
||||
return {
|
||||
"status": "healthy",
|
||||
"type": "redis",
|
||||
"message": "Redis connection successful",
|
||||
}
|
||||
except Exception as error:
|
||||
return {"status": "unhealthy", "type": "redis", "message": f"Redis connection failed: {error}"}
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"type": "redis",
|
||||
"message": f"Redis connection failed: {error}",
|
||||
}
|
||||
|
||||
|
||||
async def _check_migrations() -> dict:
|
||||
if settings.USE_IN_MEMORY_DB:
|
||||
return {"status": "healthy", "message": "Using in-memory database, no migrations needed"}
|
||||
return {
|
||||
"status": "healthy",
|
||||
"message": "Using in-memory database, no migrations needed",
|
||||
}
|
||||
try:
|
||||
conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_name IN ('projects', 'asset_libraries', 'assets', 'ingest_jobs', 'classification_jobs')
|
||||
"""
|
||||
)
|
||||
""")
|
||||
count = cur.fetchone()[0]
|
||||
conn.close()
|
||||
if count >= 5:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from typing import Any
|
||||
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_ingest_job_repository
|
||||
from app.schemas.ingest_job import IngestJobResponse, SubmitIngestJobRequest
|
||||
from packages.adapters.sqlalchemy_impl import SQLAlchemyIngestJobRepository
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
|
||||
router = APIRouter()
|
||||
@@ -12,7 +13,7 @@ router = APIRouter()
|
||||
@router.get("/{job_id}", response_model=IngestJobResponse)
|
||||
def get_ingest_job(
|
||||
job_id: str,
|
||||
ingest_job_repository: SQLAlchemyIngestJobRepository = Depends(get_ingest_job_repository),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
) -> IngestJobResponse:
|
||||
job = ingest_job_repository.get(job_id)
|
||||
if job is None:
|
||||
@@ -32,7 +33,7 @@ def get_ingest_job(
|
||||
@router.post("", response_model=IngestJobResponse)
|
||||
def submit_ingest_job(
|
||||
request: SubmitIngestJobRequest,
|
||||
ingest_job_repository: SQLAlchemyIngestJobRepository = Depends(get_ingest_job_repository),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
) -> IngestJobResponse:
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
job = use_case.execute(
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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")
|
||||
@@ -1,4 +1,5 @@
|
||||
"""项目管理 API 路由"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
@@ -11,7 +12,6 @@ from packages.adapters.sqlite_tracker.project_management_repositories import (
|
||||
SQLiteTaskRepository,
|
||||
)
|
||||
from packages.application.get_task_detail_use_case import GetTaskDetailUseCase
|
||||
from packages.application.update_task_use_case import UpdateTaskUseCase
|
||||
from packages.application.project_management_use_cases import (
|
||||
CreateMilestoneUseCase,
|
||||
CreateTaskIssueUseCase,
|
||||
@@ -23,6 +23,7 @@ from packages.application.project_management_use_cases import (
|
||||
UpdateTaskProgressUseCase,
|
||||
UpdateTaskStatusUseCase,
|
||||
)
|
||||
from packages.application.update_task_use_case import UpdateTaskUseCase
|
||||
from packages.domain import TaskPriority, TaskStatus
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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_project_repository,
|
||||
get_project_title_repository,
|
||||
get_workspace_member_repository,
|
||||
)
|
||||
from app.schemas.project_title import (
|
||||
CreateProjectTitleRequest,
|
||||
ListProjectTitlesResponse,
|
||||
ProjectTitleResponse,
|
||||
UpdateProjectTitleRequest,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_response(item) -> ProjectTitleResponse:
|
||||
return ProjectTitleResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
text=item.text,
|
||||
category=item.category,
|
||||
favorite=bool(getattr(item, "favorite", False)),
|
||||
usage_count=int(item.usage_count or 0),
|
||||
is_active=bool(item.is_active),
|
||||
created_at=item.created_at,
|
||||
updated_at=item.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _get_project_or_404(project_id: str, project_repository: Any):
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
return project
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/titles", response_model=ListProjectTitlesResponse)
|
||||
def list_project_titles(
|
||||
project_id: str,
|
||||
active_only: bool = False,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
title_repository: Any = Depends(get_project_title_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ListProjectTitlesResponse:
|
||||
project = _get_project_or_404(project_id, project_repository)
|
||||
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
|
||||
return ListProjectTitlesResponse(
|
||||
items=[_to_response(item) for item in title_repository.list_by_project(project_id, active_only)]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/titles", response_model=ProjectTitleResponse)
|
||||
def create_project_title(
|
||||
project_id: str,
|
||||
request: CreateProjectTitleRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
title_repository: Any = Depends(get_project_title_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ProjectTitleResponse:
|
||||
project = _get_project_or_404(project_id, project_repository)
|
||||
if project.workspace_id != request.workspace_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="workspace_id does not match project")
|
||||
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
|
||||
item = title_repository.create(
|
||||
workspace_id=project.workspace_id,
|
||||
project_id=project_id,
|
||||
text=request.text,
|
||||
category=request.category,
|
||||
favorite=request.favorite,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
)
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@router.patch("/project-titles/{title_id}", response_model=ProjectTitleResponse)
|
||||
def update_project_title(
|
||||
title_id: str,
|
||||
request: UpdateProjectTitleRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
title_repository: Any = Depends(get_project_title_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ProjectTitleResponse:
|
||||
item = title_repository.get(title_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project title not found")
|
||||
require_workspace_member(item.workspace_id, authenticated_user, workspace_member_repository)
|
||||
if request.text is not None:
|
||||
item.text = request.text.strip()
|
||||
if request.category is not None:
|
||||
item.category = request.category
|
||||
if request.favorite is not None:
|
||||
item.favorite = request.favorite
|
||||
if request.is_active is not None:
|
||||
item.is_active = request.is_active
|
||||
return _to_response(title_repository.update(item))
|
||||
@@ -1,38 +1,80 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from typing import Any
|
||||
|
||||
from app.dependencies import get_project_repository
|
||||
from app.schemas.project import CreateProjectRequest, ListProjectsResponse, ProjectResponse
|
||||
from packages.adapters.sqlalchemy_impl import SQLAlchemyProjectRepository
|
||||
from packages.application import CreateProjectCommand, CreateProjectUseCase, ListProjectsUseCase
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_project_repository, get_workspace_member_repository
|
||||
from app.schemas.project import (
|
||||
CreateProjectRequest,
|
||||
ListProjectsResponse,
|
||||
ProjectResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from packages.application import (
|
||||
CreateProjectCommand,
|
||||
CreateProjectUseCase,
|
||||
GetProjectUseCase,
|
||||
ListProjectsUseCase,
|
||||
)
|
||||
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 _to_project_response(item) -> ProjectResponse:
|
||||
return ProjectResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectResponse)
|
||||
def get_project(
|
||||
project_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ProjectResponse:
|
||||
use_case = GetProjectUseCase(project_repository)
|
||||
project = use_case.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)
|
||||
return _to_project_response(project)
|
||||
|
||||
|
||||
@router.get("", response_model=ListProjectsResponse)
|
||||
def list_projects(
|
||||
workspace_id: str,
|
||||
project_repository: SQLAlchemyProjectRepository = Depends(get_project_repository),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ListProjectsResponse:
|
||||
_require_workspace_member(workspace_id, authenticated_user, workspace_member_repository)
|
||||
use_case = ListProjectsUseCase(project_repository)
|
||||
projects = use_case.execute(workspace_id)
|
||||
return ListProjectsResponse(
|
||||
items=[
|
||||
ProjectResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
)
|
||||
for item in projects
|
||||
]
|
||||
)
|
||||
return ListProjectsResponse(items=[_to_project_response(item) for item in projects])
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectResponse)
|
||||
def create_project(
|
||||
request: CreateProjectRequest,
|
||||
project_repository: SQLAlchemyProjectRepository = Depends(get_project_repository),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ProjectResponse:
|
||||
_require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository)
|
||||
use_case = CreateProjectUseCase(project_repository)
|
||||
project = use_case.execute(
|
||||
CreateProjectCommand(
|
||||
@@ -41,9 +83,4 @@ def create_project(
|
||||
description=request.description,
|
||||
)
|
||||
)
|
||||
return ProjectResponse(
|
||||
id=project.id,
|
||||
workspace_id=project.workspace_id,
|
||||
name=project.name,
|
||||
description=project.description,
|
||||
)
|
||||
return _to_project_response(project)
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
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 (
|
||||
get_generation_task_repository,
|
||||
get_ingest_job_repository,
|
||||
get_project_repository,
|
||||
get_workspace_member_repository,
|
||||
)
|
||||
from app.schemas.task_center import ListProjectTasksResponse, ProjectTaskResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
SubmitIngestJobCommand,
|
||||
SubmitIngestJobUseCase,
|
||||
)
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _humanize_task_error(error_message: str) -> str:
|
||||
raw = (error_message or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
lower = raw.lower()
|
||||
if "ffmpeg" in lower or "ffprobe" in lower or "invalid data" in lower or "moov atom" in lower:
|
||||
return "视频素材格式无法识别,请重新导出为常见 MP4/H.264 后再试。"
|
||||
if "oss" in lower or "bucket" in lower or "storage" in lower:
|
||||
return "素材存储服务读取或写入失败,请稍后重试或联系小虾检查 OSS。"
|
||||
if "not found" in lower or "no such file" in lower:
|
||||
return "任务依赖的素材或文件不存在,请确认素材仍在项目中。"
|
||||
return f"任务失败:{raw}"
|
||||
|
||||
|
||||
def _generation_step(task) -> str:
|
||||
if task.status.value == "pending":
|
||||
return "等待 Worker 执行"
|
||||
if task.status.value == "running":
|
||||
return "正在生成成片"
|
||||
if task.status.value == "completed":
|
||||
return "生成完成"
|
||||
if task.status.value == "failed":
|
||||
return "生成失败"
|
||||
return task.status.value
|
||||
|
||||
|
||||
def _ingest_step(job) -> str:
|
||||
if job.status.value == "pending":
|
||||
return "等待导入"
|
||||
if job.status.value == "processing":
|
||||
return "正在分析素材"
|
||||
if job.status.value == "completed":
|
||||
return "导入完成"
|
||||
if job.status.value == "failed":
|
||||
return "导入失败"
|
||||
return job.status.value
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse)
|
||||
def list_project_tasks(
|
||||
project_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ListProjectTasksResponse:
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
|
||||
|
||||
items: list[ProjectTaskResponse] = []
|
||||
for job in ingest_job_repository.list_by_project(project_id):
|
||||
items.append(
|
||||
ProjectTaskResponse(
|
||||
id=f"ingest:{job.id}",
|
||||
task_type="ingest",
|
||||
workspace_id=job.workspace_id,
|
||||
project_id=job.project_id,
|
||||
status=job.status.value,
|
||||
progress=100.0 if job.status.value == "completed" else 0.0,
|
||||
current_step=_ingest_step(job),
|
||||
error_message=job.error_message,
|
||||
user_message=_humanize_task_error(job.error_message),
|
||||
retryable=job.status.value == "failed",
|
||||
source_id=job.id,
|
||||
created_at=job.created_at,
|
||||
updated_at=job.updated_at,
|
||||
)
|
||||
)
|
||||
for task in generation_task_repository.list_by_project(project_id):
|
||||
items.append(
|
||||
ProjectTaskResponse(
|
||||
id=f"generation:{task.id}",
|
||||
task_type="generation",
|
||||
workspace_id=task.workspace_id,
|
||||
project_id=task.project_id,
|
||||
status=task.status.value,
|
||||
progress=task.progress,
|
||||
current_step=_generation_step(task),
|
||||
error_message=task.error_message,
|
||||
user_message=_humanize_task_error(task.error_message),
|
||||
retryable=task.status.value == "failed",
|
||||
source_id=task.id,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.completed_at or task.started_at or task.created_at,
|
||||
)
|
||||
)
|
||||
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
|
||||
return ListProjectTasksResponse(items=items)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_type}/{source_id}/retry", response_model=ProjectTaskResponse)
|
||||
def retry_project_task(
|
||||
task_type: str,
|
||||
source_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ProjectTaskResponse:
|
||||
if task_type == "generation":
|
||||
task = generation_task_repository.get(source_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Generation task not found")
|
||||
require_workspace_member(task.workspace_id, authenticated_user, workspace_member_repository)
|
||||
if task.status.value != "failed":
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
retried = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
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,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[retried.id])
|
||||
return ProjectTaskResponse(
|
||||
id=f"generation:{retried.id}",
|
||||
task_type="generation",
|
||||
workspace_id=retried.workspace_id,
|
||||
project_id=retried.project_id,
|
||||
status=retried.status.value,
|
||||
progress=retried.progress,
|
||||
current_step=_generation_step(retried),
|
||||
source_id=retried.id,
|
||||
created_at=retried.created_at,
|
||||
updated_at=retried.created_at,
|
||||
)
|
||||
if task_type == "ingest":
|
||||
job = ingest_job_repository.get(source_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Ingest job not found")
|
||||
require_workspace_member(job.workspace_id, authenticated_user, workspace_member_repository)
|
||||
if job.status.value != "failed":
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
retried = use_case.execute(
|
||||
SubmitIngestJobCommand(
|
||||
workspace_id=job.workspace_id,
|
||||
project_id=job.project_id,
|
||||
library_id=job.library_id,
|
||||
storage_key=job.storage_key,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.ingest_asset", args=[retried.id])
|
||||
return ProjectTaskResponse(
|
||||
id=f"ingest:{retried.id}",
|
||||
task_type="ingest",
|
||||
workspace_id=retried.workspace_id,
|
||||
project_id=retried.project_id,
|
||||
status=retried.status.value,
|
||||
progress=0,
|
||||
current_step=_ingest_step(retried),
|
||||
source_id=retried.id,
|
||||
created_at=retried.created_at,
|
||||
updated_at=retried.updated_at,
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Unsupported task type")
|
||||
@@ -1,35 +1,64 @@
|
||||
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import get_settings
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import MinIOService, get_minio_service
|
||||
from app.dependencies import get_ingest_job_repository
|
||||
from app.schemas.upload import UploadAssetResponse
|
||||
from packages.adapters.sqlalchemy_impl import SQLAlchemyIngestJobRepository
|
||||
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_ingest_job_repository,
|
||||
get_project_repository,
|
||||
get_workspace_member_repository,
|
||||
)
|
||||
from app.schemas.upload import (
|
||||
DirectUploadCompleteRequest,
|
||||
DirectUploadCompleteResponse,
|
||||
DirectUploadPrepareRequest,
|
||||
DirectUploadPrepareResponse,
|
||||
UploadAssetResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
|
||||
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=UploadAssetResponse)
|
||||
async def upload_asset(
|
||||
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
|
||||
workspace_id: str = Form(..., description="工作空间 ID"),
|
||||
project_id: str = Form(..., description="项目 ID"),
|
||||
library_id: str = Form(..., description="素材库 ID"),
|
||||
ingest_job_repository: SQLAlchemyIngestJobRepository = Depends(get_ingest_job_repository),
|
||||
storage_service: MinIOService = Depends(get_minio_service),
|
||||
) -> UploadAssetResponse:
|
||||
"""上传素材文件并触发导入流水线。"""
|
||||
file_id = uuid4().hex[:8]
|
||||
storage_key = f"uploads/{file_id}/{file.filename}"
|
||||
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")
|
||||
|
||||
file_url = storage_service.upload_file(
|
||||
file.file,
|
||||
storage_key,
|
||||
content_type=file.content_type or "application/octet-stream",
|
||||
)
|
||||
|
||||
def _require_project_and_library(
|
||||
workspace_id: str,
|
||||
project_id: str,
|
||||
library_id: str,
|
||||
project_repository: Any,
|
||||
asset_library_repository: Any,
|
||||
) -> None:
|
||||
project = GetProjectUseCase(project_repository).execute(project_id)
|
||||
if project is None or project.workspace_id != workspace_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
libraries = asset_library_repository.list_by_project(project_id)
|
||||
if not any(item.id == library_id and item.workspace_id == workspace_id for item in libraries):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
|
||||
|
||||
|
||||
def _submit_ingest_job(
|
||||
workspace_id: str,
|
||||
project_id: str,
|
||||
library_id: str,
|
||||
storage_key: str,
|
||||
ingest_job_repository: Any,
|
||||
) -> Any:
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
job = use_case.execute(
|
||||
SubmitIngestJobCommand(
|
||||
@@ -39,8 +68,128 @@ async def upload_asset(
|
||||
storage_key=storage_key,
|
||||
)
|
||||
)
|
||||
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
return job
|
||||
|
||||
|
||||
@router.post("/direct/prepare", response_model=DirectUploadPrepareResponse)
|
||||
async def prepare_direct_upload(
|
||||
request: DirectUploadPrepareRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> DirectUploadPrepareResponse:
|
||||
"""创建浏览器直传 OSS 的短期表单签名。"""
|
||||
settings = get_settings()
|
||||
max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024
|
||||
if request.file_size > max_size_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"File exceeds upload limit ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
|
||||
)
|
||||
|
||||
_require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository)
|
||||
_require_project_and_library(
|
||||
request.workspace_id,
|
||||
request.project_id,
|
||||
request.library_id,
|
||||
project_repository,
|
||||
asset_library_repository,
|
||||
)
|
||||
|
||||
file_id = uuid4().hex[:8]
|
||||
safe_filename = request.filename.replace("/", "_").replace("\\", "_")
|
||||
storage_key = f"uploads/{file_id}/{safe_filename}"
|
||||
try:
|
||||
payload = storage_service.create_direct_upload_post(
|
||||
storage_key=storage_key,
|
||||
content_type=request.content_type or "application/octet-stream",
|
||||
max_size_bytes=max_size_bytes,
|
||||
expires_seconds=settings.OSS_DIRECT_UPLOAD_EXPIRE_SECONDS,
|
||||
)
|
||||
except RuntimeError as error:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(error)) from error
|
||||
|
||||
return DirectUploadPrepareResponse(
|
||||
upload_url=str(payload["url"]),
|
||||
method=str(payload["method"]),
|
||||
storage_key=str(payload["storage_key"]),
|
||||
expires_at=str(payload["expires_at"]),
|
||||
fields={str(key): str(value) for key, value in dict(payload["fields"]).items()},
|
||||
max_size_bytes=max_size_bytes,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/direct/complete", response_model=DirectUploadCompleteResponse)
|
||||
async def complete_direct_upload(
|
||||
request: DirectUploadCompleteRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> DirectUploadCompleteResponse:
|
||||
"""确认浏览器直传完成并创建导入任务。"""
|
||||
_require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository)
|
||||
_require_project_and_library(
|
||||
request.workspace_id,
|
||||
request.project_id,
|
||||
request.library_id,
|
||||
project_repository,
|
||||
asset_library_repository,
|
||||
)
|
||||
normalized_key = storage_service._normalize_storage_key(request.storage_key)
|
||||
if not normalized_key.startswith("uploads/"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid upload key")
|
||||
if not storage_service.file_exists(normalized_key):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found")
|
||||
|
||||
job = _submit_ingest_job(
|
||||
workspace_id=request.workspace_id,
|
||||
project_id=request.project_id,
|
||||
library_id=request.library_id,
|
||||
storage_key=normalized_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
)
|
||||
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
|
||||
|
||||
|
||||
@router.post("", response_model=UploadAssetResponse)
|
||||
async def upload_asset(
|
||||
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
|
||||
workspace_id: str = Form(..., description="工作空间 ID"),
|
||||
project_id: str = Form(..., description="项目 ID"),
|
||||
library_id: str = Form(..., description="素材库 ID"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> UploadAssetResponse:
|
||||
"""上传素材文件并触发导入流水线。"""
|
||||
_require_workspace_member(workspace_id, authenticated_user, workspace_member_repository)
|
||||
_require_project_and_library(workspace_id, project_id, library_id, project_repository, asset_library_repository)
|
||||
|
||||
file_id = uuid4().hex[:8]
|
||||
storage_key = f"uploads/{file_id}/{file.filename}"
|
||||
|
||||
file_url = storage_service.upload_file(
|
||||
file.file,
|
||||
storage_key,
|
||||
content_type=file.content_type or "application/octet-stream",
|
||||
)
|
||||
|
||||
job = _submit_ingest_job(
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
storage_key=storage_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
)
|
||||
|
||||
return UploadAssetResponse(
|
||||
storage_key=storage_key,
|
||||
|
||||
@@ -1,21 +1,41 @@
|
||||
"""
|
||||
Workspace API 路由(完整实现)
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
Workspace API routes wired through the canonical API composition root.
|
||||
|
||||
from packages.application.workspace import *
|
||||
from packages.domain.entities import User
|
||||
from apps.api.app.dependencies import get_container
|
||||
from apps.api.app.middleware.auth import get_current_user, require_workspace_access, require_workspace_admin, require_workspace_owner
|
||||
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"])
|
||||
|
||||
|
||||
# ==================== Request/Response Models ====================
|
||||
|
||||
class CreateWorkspaceRequestModel(BaseModel):
|
||||
name: str
|
||||
subscription_plan: str = "free"
|
||||
@@ -29,41 +49,104 @@ class WorkspaceResponseModel(BaseModel):
|
||||
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 # admin, member, viewer
|
||||
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 UpgradeSubscriptionRequestModel(BaseModel):
|
||||
new_plan: str # pro, enterprise
|
||||
class UpdateMemberRoleResponseModel(BaseModel):
|
||||
user_id: str
|
||||
old_role: str
|
||||
new_role: str
|
||||
|
||||
|
||||
# ==================== Workspace CRUD ====================
|
||||
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,
|
||||
current_user: User = Depends(get_current_user),
|
||||
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),
|
||||
):
|
||||
"""创建工作空间"""
|
||||
container = get_container()
|
||||
use_case = container.get_create_workspace_use_case()
|
||||
|
||||
req = CreateWorkspaceRequest(
|
||||
name=request.name,
|
||||
owner_user_id=current_user.id,
|
||||
subscription_plan=request.subscription_plan,
|
||||
use_case = CreateWorkspaceUseCase(
|
||||
workspace_repository=workspace_repository,
|
||||
workspace_member_repository=workspace_member_repository,
|
||||
user_repository=user_repository,
|
||||
)
|
||||
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
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,
|
||||
@@ -73,303 +156,214 @@ async def create_workspace(
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_workspaces(current_user: User = Depends(get_current_user)):
|
||||
"""获取用户的所有工作空间"""
|
||||
container = get_container()
|
||||
use_case = container.get_list_workspaces_use_case()
|
||||
|
||||
req = ListWorkspacesRequest(user_id=current_user.id)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
return {
|
||||
"workspaces": [
|
||||
{
|
||||
"workspace_id": ws.workspace_id,
|
||||
"name": ws.name,
|
||||
"subscription_plan": ws.subscription_plan,
|
||||
"max_projects": ws.max_projects,
|
||||
"max_storage_gb": ws.max_storage_gb,
|
||||
"member_count": ws.member_count,
|
||||
"user_role": ws.user_role,
|
||||
}
|
||||
for ws in response.workspaces
|
||||
@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.get("/{workspace_id}")
|
||||
@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,
|
||||
current_user: User = Depends(get_current_user),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
):
|
||||
"""获取工作空间详情"""
|
||||
container = get_container()
|
||||
use_case = container.get_get_workspace_detail_use_case()
|
||||
|
||||
req = GetWorkspaceDetailRequest(workspace_id=workspace_id, user_id=current_user.id)
|
||||
detail, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error)
|
||||
|
||||
return {
|
||||
"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,
|
||||
}
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# ==================== Member Management ====================
|
||||
|
||||
@router.post("/{workspace_id}/members/invite", status_code=status.HTTP_201_CREATED)
|
||||
@router.post(
|
||||
"/{workspace_id}/members/invite", response_model=InviteMemberResponseModel, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def invite_member(
|
||||
workspace_id: str,
|
||||
request: InviteMemberRequestModel,
|
||||
current_user: User = Depends(get_current_user),
|
||||
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),
|
||||
):
|
||||
"""邀请成员"""
|
||||
container = get_container()
|
||||
use_case = container.get_invite_member_use_case()
|
||||
|
||||
req = InviteMemberRequest(
|
||||
workspace_id=workspace_id,
|
||||
inviter_user_id=current_user.id,
|
||||
invitee_email=request.email,
|
||||
role=request.role,
|
||||
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(),
|
||||
)
|
||||
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
return {
|
||||
"invitation_id": response.invitation_id,
|
||||
"invitee_email": response.invitee_email,
|
||||
"role": response.role,
|
||||
"expires_at": response.expires_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{workspace_id}/members")
|
||||
@router.get("/{workspace_id}/members", response_model=MemberListResponseModel)
|
||||
async def list_members(
|
||||
workspace_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
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),
|
||||
):
|
||||
"""获取成员列表"""
|
||||
container = get_container()
|
||||
use_case = container.get_list_members_use_case()
|
||||
|
||||
req = ListMembersRequest(workspace_id=workspace_id, requester_user_id=current_user.id)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error)
|
||||
|
||||
return {
|
||||
"members": [
|
||||
{
|
||||
"member_id": m.member_id,
|
||||
"user_id": m.user_id,
|
||||
"username": m.username,
|
||||
"email": m.email,
|
||||
"display_name": m.display_name,
|
||||
"role": m.role,
|
||||
"invited_by": m.invited_by,
|
||||
"joined_at": m.joined_at.isoformat(),
|
||||
}
|
||||
for m in response.members
|
||||
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,
|
||||
current_user: User = Depends(get_current_user),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
):
|
||||
"""移除成员"""
|
||||
container = get_container()
|
||||
use_case = container.get_remove_member_use_case()
|
||||
|
||||
req = RemoveMemberRequest(
|
||||
workspace_id=workspace_id,
|
||||
requester_user_id=current_user.id,
|
||||
target_user_id=user_id,
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
|
||||
@router.post("/{workspace_id}/leave", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def leave_workspace(
|
||||
workspace_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""离开工作空间"""
|
||||
container = get_container()
|
||||
use_case = container.get_leave_workspace_use_case()
|
||||
|
||||
req = LeaveWorkspaceRequest(workspace_id=workspace_id, user_id=current_user.id)
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
|
||||
@router.patch("/{workspace_id}/members/{user_id}/role")
|
||||
async def update_member_role(
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
request: UpdateMemberRoleRequestModel,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""修改成员角色"""
|
||||
container = get_container()
|
||||
use_case = container.get_update_member_role_use_case()
|
||||
|
||||
req = UpdateMemberRoleRequest(
|
||||
workspace_id=workspace_id,
|
||||
requester_user_id=current_user.id,
|
||||
target_user_id=user_id,
|
||||
new_role=request.role,
|
||||
)
|
||||
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
return {
|
||||
"user_id": response.user_id,
|
||||
"old_role": response.old_role,
|
||||
"new_role": response.new_role,
|
||||
}
|
||||
|
||||
|
||||
# ==================== Subscription Management ====================
|
||||
|
||||
@router.post("/{workspace_id}/subscription/upgrade")
|
||||
async def upgrade_subscription(
|
||||
workspace_id: str,
|
||||
request: UpgradeSubscriptionRequestModel,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""升级订阅"""
|
||||
container = get_container()
|
||||
use_case = container.get_upgrade_subscription_use_case()
|
||||
|
||||
req = UpgradeSubscriptionRequest(
|
||||
workspace_id=workspace_id,
|
||||
requester_user_id=current_user.id,
|
||||
new_plan=request.new_plan,
|
||||
)
|
||||
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
return {
|
||||
"workspace_id": response.workspace_id,
|
||||
"old_plan": response.old_plan,
|
||||
"new_plan": response.new_plan,
|
||||
"max_projects": response.max_projects,
|
||||
"max_storage_gb": response.max_storage_gb,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{workspace_id}/subscription/cancel")
|
||||
async def cancel_subscription(
|
||||
workspace_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""取消订阅"""
|
||||
container = get_container()
|
||||
use_case = container.get_cancel_subscription_use_case()
|
||||
|
||||
req = CancelSubscriptionRequest(workspace_id=workspace_id, requester_user_id=current_user.id)
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
return {"message": "Subscription cancelled successfully"}
|
||||
|
||||
|
||||
@router.get("/{workspace_id}/quota")
|
||||
async def get_quota_status(
|
||||
workspace_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取配额状态"""
|
||||
container = get_container()
|
||||
quota_checker = container.quota_checker
|
||||
|
||||
# 检查权限
|
||||
permission_checker = container.permission_checker
|
||||
has_access, _ = permission_checker.check_workspace_access(workspace_id, current_user.id)
|
||||
|
||||
if not has_access:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
|
||||
status = quota_checker.get_quota_status(workspace_id)
|
||||
|
||||
if not status:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
|
||||
|
||||
return status
|
||||
|
||||
|
||||
# ==================== Invitation Acceptance ====================
|
||||
|
||||
@router.post("/invitations/{token}/accept")
|
||||
async def accept_invitation(
|
||||
token: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""接受邀请"""
|
||||
container = get_container()
|
||||
use_case = container.get_accept_invitation_use_case()
|
||||
|
||||
req = AcceptInvitationRequest(invitation_token=token, user_id=current_user.id)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
return {
|
||||
"workspace_id": response.workspace_id,
|
||||
"workspace_name": response.workspace_name,
|
||||
"role": response.role,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/invitations/{token}/decline")
|
||||
async def decline_invitation(token: str):
|
||||
"""拒绝邀请"""
|
||||
container = get_container()
|
||||
use_case = container.get_decline_invitation_use_case()
|
||||
|
||||
req = DeclineInvitationRequest(invitation_token=token)
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
return {"message": "Invitation declined"}
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to remove member")
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import jwt
|
||||
from app.config import settings
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jwt import ExpiredSignatureError, InvalidTokenError
|
||||
|
||||
from packages.domain.entities import User
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
from .dependencies import get_user_repository
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthenticatedUser:
|
||||
user: User
|
||||
session_id: str | None = None
|
||||
token_type: str | None = None
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> AuthenticatedUser:
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise _unauthorized("Missing bearer token")
|
||||
|
||||
payload = _decode_user_token(credentials.credentials)
|
||||
user_id = payload.get("sub")
|
||||
if not isinstance(user_id, str) or not user_id:
|
||||
raise _unauthorized("Invalid token subject")
|
||||
|
||||
user = user_repository.find_by_id(user_id)
|
||||
if user is None:
|
||||
raise _unauthorized("User no longer exists")
|
||||
|
||||
return AuthenticatedUser(
|
||||
user=user,
|
||||
session_id=payload.get("sid"),
|
||||
token_type=payload.get("type"),
|
||||
)
|
||||
|
||||
|
||||
def _decode_user_token(token: str) -> dict:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
|
||||
except ExpiredSignatureError:
|
||||
raise _unauthorized("Token expired") from None
|
||||
except InvalidTokenError:
|
||||
raise _unauthorized("Invalid token") from None
|
||||
|
||||
if payload.get("type") not in {"user_auth", "access"}:
|
||||
raise _unauthorized("Invalid token type")
|
||||
return payload
|
||||
|
||||
|
||||
def _unauthorized(detail: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=detail,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
+33
-25
@@ -1,6 +1,7 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import Optional
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -9,7 +10,8 @@ class Settings(BaseSettings):
|
||||
ENVIRONMENT: str = "development"
|
||||
DEBUG: bool = True
|
||||
|
||||
API_HOST: str = "0.0.0.0"
|
||||
# Container bind address; external exposure is controlled by Docker/Nginx.
|
||||
API_HOST: str = "0.0.0.0" # nosec B104
|
||||
API_PORT: int = 8000
|
||||
API_PREFIX: str = "/api/v1"
|
||||
|
||||
@@ -19,19 +21,33 @@ class Settings(BaseSettings):
|
||||
DATABASE_POOL_TIMEOUT: int = 30
|
||||
DATABASE_POOL_RECYCLE: int = 3600
|
||||
USE_IN_MEMORY_DB: bool = False
|
||||
AUTO_CREATE_SCHEMA: bool = False
|
||||
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
REDIS_MAX_CONNECTIONS: int = 50
|
||||
ENABLE_REDIS_SESSIONS: bool = False
|
||||
|
||||
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
|
||||
ENABLE_EMAIL_DELIVERY: bool = False
|
||||
SMTP_HOST: str = "smtp.gmail.com"
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM_EMAIL: str = ""
|
||||
SMTP_FROM_NAME: str = "小虾 SaaS"
|
||||
SMTP_USE_TLS: bool = True
|
||||
|
||||
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
|
||||
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
|
||||
|
||||
MINIO_ENDPOINT: str = "localhost:9000"
|
||||
MINIO_ACCESS_KEY: str = "admin"
|
||||
MINIO_SECRET_KEY: str = "xiaoxia2026"
|
||||
MINIO_BUCKET: str = "xiaoxia-assets"
|
||||
MINIO_SECURE: bool = False
|
||||
MINIO_PUBLIC_URL: str = "http://localhost:9000"
|
||||
# 阿里云 OSS 配置
|
||||
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliyuncs.com"
|
||||
OSS_ACCESS_KEY_ID: str = ""
|
||||
OSS_ACCESS_KEY_SECRET: str = ""
|
||||
OSS_BUCKET_NAME: str = "xiaoxia-autocut"
|
||||
OSS_DIRECT_UPLOAD_MAX_MB: int = 800
|
||||
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS: int = 900
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
CORS_ORIGINS_RAW: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
|
||||
@@ -64,28 +80,20 @@ class Settings(BaseSettings):
|
||||
return self.CELERY_RESULT_BACKEND
|
||||
|
||||
@property
|
||||
def minio_endpoint(self) -> str:
|
||||
return self.MINIO_ENDPOINT
|
||||
def oss_endpoint(self) -> str:
|
||||
return self.OSS_ENDPOINT
|
||||
|
||||
@property
|
||||
def minio_access_key(self) -> str:
|
||||
return self.MINIO_ACCESS_KEY
|
||||
def oss_access_key_id(self) -> str:
|
||||
return self.OSS_ACCESS_KEY_ID
|
||||
|
||||
@property
|
||||
def minio_secret_key(self) -> str:
|
||||
return self.MINIO_SECRET_KEY
|
||||
def oss_access_key_secret(self) -> str:
|
||||
return self.OSS_ACCESS_KEY_SECRET
|
||||
|
||||
@property
|
||||
def minio_bucket(self) -> str:
|
||||
return self.MINIO_BUCKET
|
||||
|
||||
@property
|
||||
def minio_secure(self) -> bool:
|
||||
return self.MINIO_SECURE
|
||||
|
||||
@property
|
||||
def minio_public_url(self) -> str:
|
||||
return self.MINIO_PUBLIC_URL
|
||||
def oss_bucket_name(self) -> str:
|
||||
return self.OSS_BUCKET_NAME
|
||||
|
||||
|
||||
_settings: Optional[Settings] = None
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from celery import Celery
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
from celery import Celery
|
||||
|
||||
settings = get_settings()
|
||||
celery_app = Celery("xiaoxia-saas-api")
|
||||
|
||||
+15
-121
@@ -1,125 +1,19 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import Optional
|
||||
import os
|
||||
"""Compatibility layer for the canonical API settings module.
|
||||
|
||||
Use `app.config` as the single source of truth for API configuration.
|
||||
This module remains only for older imports during migration.
|
||||
"""
|
||||
|
||||
from app.config import Settings as AppSettings
|
||||
from app.config import get_settings, settings
|
||||
|
||||
|
||||
class AppSettings(BaseSettings):
|
||||
"""应用配置"""
|
||||
|
||||
# 基础配置
|
||||
app_name: str = "xiaoxia-saas"
|
||||
app_env: str = "development" # development / staging / production
|
||||
app_version: str = "0.1.0"
|
||||
debug: bool = True
|
||||
|
||||
# API 配置
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8000
|
||||
api_prefix: str = "/api/v1"
|
||||
|
||||
# 数据库配置
|
||||
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
|
||||
database_pool_size: int = 20
|
||||
database_max_overflow: int = 40
|
||||
database_pool_timeout: int = 30
|
||||
database_pool_recycle: int = 3600
|
||||
|
||||
# Redis 配置
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
redis_max_connections: int = 50
|
||||
|
||||
# Celery 配置
|
||||
celery_broker_url: str = "redis://localhost:6379/0"
|
||||
celery_result_backend: str = "redis://localhost:6379/1"
|
||||
celery_worker_concurrency: int = 4
|
||||
celery_worker_max_tasks_per_child: int = 1000
|
||||
|
||||
# MinIO 配置
|
||||
minio_endpoint: str = "localhost:9000"
|
||||
minio_access_key: str = "admin"
|
||||
minio_secret_key: str = "xiaoxia2026"
|
||||
minio_bucket: str = "xiaoxia-assets"
|
||||
minio_secure: bool = False
|
||||
minio_public_url: str = "http://localhost:9000"
|
||||
|
||||
# 日志配置
|
||||
log_level: str = "INFO"
|
||||
log_format: str = "json" # json / text
|
||||
log_file: Optional[str] = None
|
||||
|
||||
# CORS 配置
|
||||
cors_origins: str = "http://localhost:3000,http://localhost:8000"
|
||||
cors_allow_credentials: bool = True
|
||||
|
||||
# 文件上传限制
|
||||
max_upload_size_mb: int = 1000
|
||||
allowed_file_types: str = "video/mp4,video/quicktime,video/x-msvideo,audio/mpeg,audio/wav,image/jpeg,image/png,image/gif"
|
||||
|
||||
# 安全配置
|
||||
secret_key: str = "change-me-in-production"
|
||||
access_token_expire_minutes: int = 60
|
||||
refresh_token_expire_days: int = 7
|
||||
|
||||
# 监控配置(可选)
|
||||
sentry_dsn: Optional[str] = None
|
||||
prometheus_port: Optional[int] = None
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
"""解析 CORS origins 为列表"""
|
||||
return [origin.strip() for origin in self.cors_origins.split(",")]
|
||||
|
||||
@property
|
||||
def allowed_file_types_list(self) -> list[str]:
|
||||
"""解析允许的文件类型为列表"""
|
||||
return [ft.strip() for ft in self.allowed_file_types.split(",")]
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
"""是否为生产环境"""
|
||||
return self.app_env == "production"
|
||||
|
||||
@property
|
||||
def is_staging(self) -> bool:
|
||||
"""是否为 staging 环境"""
|
||||
return self.app_env == "staging"
|
||||
|
||||
@property
|
||||
def is_development(self) -> bool:
|
||||
"""是否为开发环境"""
|
||||
return self.app_env == "development"
|
||||
def reload_settings() -> AppSettings:
|
||||
"""Reload settings for tests and legacy callers."""
|
||||
import app.config as canonical_config
|
||||
|
||||
canonical_config.settings = canonical_config.get_settings()
|
||||
return canonical_config.settings
|
||||
|
||||
|
||||
# 全局配置实例
|
||||
_settings: Optional[AppSettings] = None
|
||||
|
||||
|
||||
def get_settings() -> AppSettings:
|
||||
"""获取配置实例(单例模式)"""
|
||||
global _settings
|
||||
if _settings is None:
|
||||
# 根据环境加载不同的 .env 文件
|
||||
env = os.getenv("APP_ENV", "development")
|
||||
env_file = f".env.{env}" if env != "development" else ".env"
|
||||
|
||||
# 如果环境特定的配置文件存在,则使用它
|
||||
if os.path.exists(env_file):
|
||||
_settings = AppSettings(_env_file=env_file)
|
||||
else:
|
||||
_settings = AppSettings()
|
||||
|
||||
return _settings
|
||||
|
||||
|
||||
def reload_settings():
|
||||
"""重新加载配置(用于测试)"""
|
||||
global _settings
|
||||
_settings = None
|
||||
return get_settings()
|
||||
__all__ = ["AppSettings", "get_settings", "reload_settings", "settings"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import Optional
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class DatabaseSettings(BaseSettings):
|
||||
|
||||
+207
-59
@@ -1,84 +1,232 @@
|
||||
"""MinIO storage service for file uploads."""
|
||||
from typing import BinaryIO
|
||||
"""阿里云 OSS 存储服务"""
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
|
||||
try:
|
||||
import oss2
|
||||
except ImportError: # pragma: no cover - exercised in minimal local/test environments
|
||||
oss2 = None
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MinIOService:
|
||||
"""MinIO storage service."""
|
||||
|
||||
class OSSStorageService:
|
||||
"""阿里云 OSS 存储服务"""
|
||||
|
||||
def __init__(self):
|
||||
settings = get_settings()
|
||||
self.client = Minio(
|
||||
settings.MINIO_ENDPOINT,
|
||||
access_key=settings.MINIO_ACCESS_KEY,
|
||||
secret_key=settings.MINIO_SECRET_KEY,
|
||||
secure=settings.MINIO_SECURE,
|
||||
)
|
||||
self.bucket_name = settings.MINIO_BUCKET
|
||||
self.public_url = settings.MINIO_PUBLIC_URL
|
||||
self._ensure_bucket()
|
||||
self.bucket_name = settings.OSS_BUCKET_NAME
|
||||
self.public_url = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
|
||||
self.local_url_prefix = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files")
|
||||
self.bucket = None
|
||||
|
||||
def _ensure_bucket(self):
|
||||
try:
|
||||
if not self.client.bucket_exists(self.bucket_name):
|
||||
self.client.make_bucket(self.bucket_name)
|
||||
policy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": [f"arn:aws:s3:::{self.bucket_name}/*"],
|
||||
}
|
||||
],
|
||||
}
|
||||
import json
|
||||
self.client.set_bucket_policy(self.bucket_name, json.dumps(policy))
|
||||
except S3Error as error:
|
||||
print(f"Error ensuring bucket: {error}")
|
||||
if settings.OSS_ACCESS_KEY_ID and settings.OSS_ACCESS_KEY_SECRET:
|
||||
if oss2 is not None:
|
||||
auth = oss2.Auth(
|
||||
settings.OSS_ACCESS_KEY_ID,
|
||||
settings.OSS_ACCESS_KEY_SECRET,
|
||||
)
|
||||
self.bucket = oss2.Bucket(
|
||||
auth,
|
||||
settings.OSS_ENDPOINT,
|
||||
settings.OSS_BUCKET_NAME,
|
||||
)
|
||||
self.access_key_id = settings.OSS_ACCESS_KEY_ID
|
||||
self.access_key_secret = settings.OSS_ACCESS_KEY_SECRET
|
||||
self.endpoint = settings.OSS_ENDPOINT
|
||||
|
||||
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
|
||||
parsed = urlparse(storage_key_or_url)
|
||||
path = parsed.path if parsed.scheme else storage_key_or_url
|
||||
return path.startswith(f"{self.local_url_prefix}/")
|
||||
|
||||
def create_direct_upload_post(
|
||||
self,
|
||||
storage_key: str,
|
||||
content_type: str,
|
||||
max_size_bytes: int,
|
||||
expires_seconds: int,
|
||||
) -> dict[str, object]:
|
||||
"""创建浏览器直传 OSS 的 PostObject 表单。"""
|
||||
if not self.access_key_id or not self.access_key_secret:
|
||||
raise RuntimeError("OSS storage is not configured")
|
||||
normalized_key = self._normalize_storage_key(storage_key)
|
||||
if not normalized_key.startswith("uploads/"):
|
||||
raise ValueError("direct upload key must be under uploads/")
|
||||
|
||||
expiration = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=expires_seconds)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S.000Z"
|
||||
)
|
||||
policy = {
|
||||
"expiration": expiration,
|
||||
"conditions": [
|
||||
{"bucket": self.bucket_name},
|
||||
{"key": normalized_key},
|
||||
["content-length-range", 1, max_size_bytes],
|
||||
["starts-with", "$Content-Type", content_type.split("/", 1)[0] + "/" if "/" in content_type else ""],
|
||||
],
|
||||
}
|
||||
encoded_policy = base64.b64encode(json.dumps(policy, separators=(",", ":")).encode("utf-8")).decode("ascii")
|
||||
signature = base64.b64encode(
|
||||
hmac.new(self.access_key_secret.encode("utf-8"), encoded_policy.encode("utf-8"), hashlib.sha1).digest()
|
||||
).decode("ascii")
|
||||
|
||||
return {
|
||||
"url": self.public_url,
|
||||
"method": "POST",
|
||||
"storage_key": normalized_key,
|
||||
"expires_at": expiration,
|
||||
"fields": {
|
||||
"key": normalized_key,
|
||||
"OSSAccessKeyId": self.access_key_id,
|
||||
"policy": encoded_policy,
|
||||
"Signature": signature,
|
||||
"success_action_status": "201",
|
||||
"Content-Type": content_type,
|
||||
},
|
||||
}
|
||||
|
||||
def upload_file(
|
||||
self,
|
||||
file: BinaryIO,
|
||||
file_or_path,
|
||||
storage_key: str,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> str:
|
||||
"""
|
||||
上传文件到 OSS
|
||||
|
||||
Args:
|
||||
file_or_path: 文件对象或本地文件路径
|
||||
storage_key: 存储键(文件路径)
|
||||
content_type: 内容类型
|
||||
|
||||
Returns:
|
||||
文件公网 URL
|
||||
"""
|
||||
if self.bucket is None:
|
||||
raise RuntimeError("OSS storage is not configured")
|
||||
|
||||
try:
|
||||
file.seek(0, os.SEEK_END)
|
||||
file_size = file.tell()
|
||||
file.seek(0)
|
||||
self.client.put_object(
|
||||
self.bucket_name,
|
||||
storage_key,
|
||||
file,
|
||||
file_size,
|
||||
content_type=content_type,
|
||||
)
|
||||
return f"{self.public_url}/{self.bucket_name}/{storage_key}"
|
||||
except S3Error as error:
|
||||
raise Exception(f"Failed to upload file: {error}")
|
||||
# 如果是字符串路径,从本地文件上传
|
||||
if isinstance(file_or_path, str):
|
||||
self.bucket.put_object_from_file(storage_key, file_or_path, headers={"Content-Type": content_type})
|
||||
else:
|
||||
# 文件对象
|
||||
file_or_path.seek(0)
|
||||
self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type})
|
||||
|
||||
return f"{self.public_url}/{storage_key}"
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to upload file to OSS: {e}")
|
||||
|
||||
def get_url(self, storage_key: str) -> str:
|
||||
return f"{self.public_url}/{self.bucket_name}/{storage_key}"
|
||||
"""获取文件公网 URL"""
|
||||
return f"{self.public_url}/{storage_key}"
|
||||
|
||||
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
|
||||
"""
|
||||
获取文件下载签名 URL(用于私有文件)
|
||||
|
||||
Args:
|
||||
storage_key_or_url: 存储键或完整 URL
|
||||
expires_seconds: 过期时间(秒)
|
||||
|
||||
Returns:
|
||||
签名 URL
|
||||
"""
|
||||
if self.bucket is None:
|
||||
if self._is_local_generated_url(storage_key_or_url):
|
||||
return storage_key_or_url
|
||||
return self.get_url(self._normalize_storage_key(storage_key_or_url))
|
||||
|
||||
storage_key = self._normalize_storage_key(storage_key_or_url)
|
||||
try:
|
||||
return self.bucket.sign_url("GET", storage_key, expires_seconds)
|
||||
except Exception:
|
||||
return self.get_url(storage_key)
|
||||
|
||||
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
|
||||
"""从 URL 中提取存储键"""
|
||||
if storage_key_or_url.startswith("http://") or storage_key_or_url.startswith("https://"):
|
||||
parsed = urlparse(storage_key_or_url)
|
||||
# 移除开头的 /
|
||||
return parsed.path.lstrip("/")
|
||||
return storage_key_or_url.lstrip("/")
|
||||
|
||||
def download_file(self, storage_key: str, local_path: str):
|
||||
"""
|
||||
从 OSS 下载文件到本地
|
||||
|
||||
Args:
|
||||
storage_key: 存储键
|
||||
local_path: 本地文件路径
|
||||
"""
|
||||
if self.bucket is None:
|
||||
raise RuntimeError("OSS storage is not configured")
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
||||
self.bucket.get_object_to_file(storage_key, local_path)
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to download file from OSS: {e}")
|
||||
|
||||
def delete_file(self, storage_key: str):
|
||||
"""
|
||||
删除 OSS 文件
|
||||
|
||||
Args:
|
||||
storage_key: 存储键
|
||||
"""
|
||||
if self.bucket is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.client.remove_object(self.bucket_name, storage_key)
|
||||
except S3Error as error:
|
||||
print(f"Error deleting file: {error}")
|
||||
self.bucket.delete_object(storage_key)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"Failed to delete file from OSS",
|
||||
extra={"storage_key": storage_key, "error": str(error)},
|
||||
)
|
||||
|
||||
def file_exists(self, storage_key: str) -> bool:
|
||||
"""
|
||||
检查文件是否存在
|
||||
|
||||
Args:
|
||||
storage_key: 存储键
|
||||
|
||||
Returns:
|
||||
是否存在
|
||||
"""
|
||||
if self.bucket is None:
|
||||
return False
|
||||
return self.bucket.object_exists(storage_key)
|
||||
|
||||
|
||||
_minio_service = None
|
||||
# 向后兼容的服务类名
|
||||
MinIOService = OSSStorageService
|
||||
|
||||
|
||||
def get_minio_service() -> MinIOService:
|
||||
global _minio_service
|
||||
if _minio_service is None:
|
||||
_minio_service = MinIOService()
|
||||
return _minio_service
|
||||
_storage_service = None
|
||||
|
||||
|
||||
def get_storage_service() -> OSSStorageService:
|
||||
"""获取存储服务实例(全局单例)"""
|
||||
global _storage_service
|
||||
if _storage_service is None:
|
||||
_storage_service = OSSStorageService()
|
||||
return _storage_service
|
||||
|
||||
|
||||
# 向后兼容的别名
|
||||
def get_minio_service() -> OSSStorageService:
|
||||
"""向后兼容:返回 OSS 服务(已替换 MinIO)"""
|
||||
return get_storage_service()
|
||||
|
||||
+10
-3
@@ -1,9 +1,14 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from app.config import settings
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from packages.adapters.sqlalchemy_impl import build_session_factory, ensure_database_exists, initialize_database
|
||||
from packages.adapters.sqlalchemy_impl import (
|
||||
build_session_factory,
|
||||
ensure_database_exists,
|
||||
initialize_database,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.schema_guard import assert_auto_create_schema_allowed
|
||||
|
||||
ensure_database_exists(settings.DATABASE_URL)
|
||||
engine, SessionLocal = build_session_factory(
|
||||
@@ -13,7 +18,9 @@ engine, SessionLocal = build_session_factory(
|
||||
pool_timeout=settings.DATABASE_POOL_TIMEOUT,
|
||||
pool_recycle=settings.DATABASE_POOL_RECYCLE,
|
||||
)
|
||||
initialize_database(engine)
|
||||
assert_auto_create_schema_allowed(settings.ENVIRONMENT, settings.AUTO_CREATE_SCHEMA)
|
||||
if settings.AUTO_CREATE_SCHEMA:
|
||||
initialize_database(engine)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
|
||||
+113
-10
@@ -1,13 +1,41 @@
|
||||
import redis
|
||||
from app.config import settings
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from packages.adapters.sqlalchemy_impl.asset_library_repository import SQLAlchemyAssetLibraryRepository
|
||||
from packages.adapters.redis import NoopSessionStore, SessionStore
|
||||
from packages.adapters.smtp import EmailConfig, NoopEmailService, get_email_service
|
||||
from packages.adapters.sqlalchemy_impl.asset_library_repository import (
|
||||
SQLAlchemyAssetLibraryRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.classification_job_repository import SQLAlchemyClassificationJobRepository
|
||||
from packages.adapters.sqlalchemy_impl.ingest_job_repository import SQLAlchemyIngestJobRepository
|
||||
from packages.adapters.sqlalchemy_impl.project_repository import SQLAlchemyProjectRepository
|
||||
from packages.adapters.sqlalchemy_impl.classification_job_repository import (
|
||||
SQLAlchemyClassificationJobRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.ingest_job_repository import (
|
||||
SQLAlchemyIngestJobRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.project_repository import (
|
||||
SQLAlchemyProjectRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.project_title_repository import (
|
||||
SQLAlchemyProjectTitleRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.session import build_session_factory
|
||||
from packages.adapters.sqlalchemy_impl.user_repository import SQLAlchemyUserRepository
|
||||
from packages.adapters.sqlalchemy_impl.workspace_invitation_repository import SQLAlchemyWorkspaceInvitationRepository
|
||||
from packages.adapters.sqlalchemy_impl.workspace_member_repository import SQLAlchemyWorkspaceMemberRepository
|
||||
from packages.adapters.sqlalchemy_impl.workspace_repository import SQLAlchemyWorkspaceRepository
|
||||
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
|
||||
|
||||
_engine, _SessionLocal = build_session_factory(settings.DATABASE_URL)
|
||||
|
||||
@@ -20,21 +48,96 @@ def get_db_session():
|
||||
session.close()
|
||||
|
||||
|
||||
def get_asset_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyAssetRepository:
|
||||
def get_asset_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyAssetRepository:
|
||||
return SQLAlchemyAssetRepository(session)
|
||||
|
||||
|
||||
def get_asset_library_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyAssetLibraryRepository:
|
||||
def get_asset_library_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyAssetLibraryRepository:
|
||||
return SQLAlchemyAssetLibraryRepository(session)
|
||||
|
||||
|
||||
def get_ingest_job_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyIngestJobRepository:
|
||||
def get_ingest_job_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyIngestJobRepository:
|
||||
return SQLAlchemyIngestJobRepository(session)
|
||||
|
||||
|
||||
def get_classification_job_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyClassificationJobRepository:
|
||||
def get_classification_job_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyClassificationJobRepository:
|
||||
return SQLAlchemyClassificationJobRepository(session)
|
||||
|
||||
|
||||
def get_project_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyProjectRepository:
|
||||
def get_generation_task_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyGenerationTaskRepository:
|
||||
return SQLAlchemyGenerationTaskRepository(session)
|
||||
|
||||
|
||||
def get_generated_video_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyGeneratedVideoRepository:
|
||||
return SQLAlchemyGeneratedVideoRepository(session)
|
||||
|
||||
|
||||
def get_project_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyProjectRepository:
|
||||
return SQLAlchemyProjectRepository(session)
|
||||
|
||||
|
||||
def get_project_title_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyProjectTitleRepository:
|
||||
return SQLAlchemyProjectTitleRepository(session)
|
||||
|
||||
|
||||
def get_user_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> UserRepository:
|
||||
return SQLAlchemyUserRepository(session)
|
||||
|
||||
|
||||
def get_workspace_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> WorkspaceRepository:
|
||||
return SQLAlchemyWorkspaceRepository(session)
|
||||
|
||||
|
||||
def get_workspace_member_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> WorkspaceMemberRepository:
|
||||
return SQLAlchemyWorkspaceMemberRepository(session)
|
||||
|
||||
|
||||
def get_workspace_invitation_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> WorkspaceInvitationRepository:
|
||||
return SQLAlchemyWorkspaceInvitationRepository(session)
|
||||
|
||||
|
||||
def get_auth_session_store():
|
||||
if not settings.ENABLE_REDIS_SESSIONS:
|
||||
return NoopSessionStore()
|
||||
return SessionStore(redis_client=redis.from_url(settings.REDIS_URL, decode_responses=True))
|
||||
|
||||
|
||||
def get_auth_email_service():
|
||||
if not settings.ENABLE_EMAIL_DELIVERY:
|
||||
return NoopEmailService()
|
||||
return get_email_service(
|
||||
EmailConfig(
|
||||
smtp_host=settings.SMTP_HOST,
|
||||
smtp_port=settings.SMTP_PORT,
|
||||
smtp_user=settings.SMTP_USER,
|
||||
smtp_password=settings.SMTP_PASSWORD,
|
||||
from_email=settings.SMTP_FROM_EMAIL,
|
||||
from_name=settings.SMTP_FROM_NAME,
|
||||
use_tls=settings.SMTP_USE_TLS,
|
||||
),
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
+43
-128
@@ -1,161 +1,76 @@
|
||||
"""
|
||||
认证中间件和依赖
|
||||
Authentication dependency compatibility layer.
|
||||
|
||||
Canonical bearer-token parsing lives in app.auth. This module remains only so
|
||||
legacy imports have a safe target while workspace dependencies are rebuilt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.auth import AuthenticatedUser
|
||||
from app.auth import get_current_user as get_authenticated_user
|
||||
from app.dependencies import get_user_repository, get_workspace_member_repository
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from packages.domain.auth import jwt_service
|
||||
from packages.domain.entities import User
|
||||
from apps.api.app.dependencies import get_container
|
||||
from packages.domain.permissions import PermissionChecker
|
||||
from packages.ports.user_repository import UserRepository
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
|
||||
security = HTTPBearer()
|
||||
optional_bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_authenticated_user),
|
||||
) -> User:
|
||||
"""
|
||||
获取当前登录用户
|
||||
|
||||
从 Authorization header 中提取 JWT token 并验证
|
||||
|
||||
Raises:
|
||||
HTTPException: Token 无效或过期
|
||||
|
||||
Returns:
|
||||
当前用户对象
|
||||
"""
|
||||
token = credentials.credentials
|
||||
|
||||
try:
|
||||
# 验证 token
|
||||
payload = jwt_service.verify_token(token)
|
||||
user_id = payload.get("sub")
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token: missing user_id",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 从数据库获取用户
|
||||
container = get_container()
|
||||
user = container.user_repository.find_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Invalid token: {str(e)}",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return authenticated_user.user
|
||||
|
||||
|
||||
async def get_current_user_optional(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False)),
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer_scheme),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> User | None:
|
||||
"""
|
||||
获取当前登录用户(可选)
|
||||
|
||||
如果没有提供 token,返回 None 而不是抛出异常
|
||||
|
||||
Returns:
|
||||
当前用户对象或 None
|
||||
"""
|
||||
if not credentials:
|
||||
if credentials is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await get_current_user(credentials)
|
||||
authenticated_user = await get_authenticated_user(credentials, user_repository)
|
||||
except HTTPException:
|
||||
return None
|
||||
return authenticated_user.user
|
||||
|
||||
|
||||
def require_workspace_access(workspace_id: str, user: User = Depends(get_current_user)) -> tuple[str, str]:
|
||||
"""
|
||||
要求用户可以访问指定工作空间
|
||||
|
||||
Args:
|
||||
workspace_id: 工作空间 ID
|
||||
user: 当前用户
|
||||
|
||||
Raises:
|
||||
HTTPException: 用户没有访问权限
|
||||
|
||||
Returns:
|
||||
(workspace_id, user_role)
|
||||
"""
|
||||
container = get_container()
|
||||
permission_checker = container.permission_checker
|
||||
|
||||
has_access, role = permission_checker.check_workspace_access(workspace_id, user.id)
|
||||
|
||||
if not has_access:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this workspace",
|
||||
)
|
||||
|
||||
def require_workspace_access(
|
||||
workspace_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> tuple[str, str]:
|
||||
has_access, role = PermissionChecker(workspace_member_repository).check_workspace_access(workspace_id, user.id)
|
||||
if not has_access or role is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this workspace")
|
||||
return workspace_id, role
|
||||
|
||||
|
||||
def require_workspace_admin(workspace_id: str, user: User = Depends(get_current_user)) -> str:
|
||||
"""
|
||||
要求用户是工作空间的 Admin 或 Owner
|
||||
|
||||
Args:
|
||||
workspace_id: 工作空间 ID
|
||||
user: 当前用户
|
||||
|
||||
Raises:
|
||||
HTTPException: 用户没有管理权限
|
||||
|
||||
Returns:
|
||||
workspace_id
|
||||
"""
|
||||
container = get_container()
|
||||
permission_checker = container.permission_checker
|
||||
|
||||
if not permission_checker.check_is_admin_or_owner(workspace_id, user.id):
|
||||
def require_workspace_admin(
|
||||
workspace_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> str:
|
||||
if not PermissionChecker(workspace_member_repository).check_is_admin_or_owner(workspace_id, user.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only workspace owner or admin can perform this action",
|
||||
)
|
||||
|
||||
return workspace_id
|
||||
|
||||
|
||||
def require_workspace_owner(workspace_id: str, user: User = Depends(get_current_user)) -> str:
|
||||
"""
|
||||
要求用户是工作空间的 Owner
|
||||
|
||||
Args:
|
||||
workspace_id: 工作空间 ID
|
||||
user: 当前用户
|
||||
|
||||
Raises:
|
||||
HTTPException: 用户不是 Owner
|
||||
|
||||
Returns:
|
||||
workspace_id
|
||||
"""
|
||||
container = get_container()
|
||||
permission_checker = container.permission_checker
|
||||
|
||||
if not permission_checker.check_is_owner(workspace_id, user.id):
|
||||
def require_workspace_owner(
|
||||
workspace_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> str:
|
||||
if not PermissionChecker(workspace_member_repository).check_is_owner(workspace_id, user.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only workspace owner can perform this action",
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Only workspace owner can perform this action"
|
||||
)
|
||||
|
||||
return workspace_id
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
"""
|
||||
全局异常处理和错误响应
|
||||
"""
|
||||
from fastapi import Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
import traceback
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from fastapi import Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class APIException(Exception):
|
||||
"""API 异常基类"""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
@@ -28,7 +30,7 @@ class APIException(Exception):
|
||||
|
||||
class AuthenticationError(APIException):
|
||||
"""认证错误"""
|
||||
|
||||
|
||||
def __init__(self, message: str = "Authentication failed"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
@@ -39,7 +41,7 @@ class AuthenticationError(APIException):
|
||||
|
||||
class PermissionDeniedError(APIException):
|
||||
"""权限拒绝"""
|
||||
|
||||
|
||||
def __init__(self, message: str = "Permission denied"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
@@ -50,7 +52,7 @@ class PermissionDeniedError(APIException):
|
||||
|
||||
class ResourceNotFoundError(APIException):
|
||||
"""资源不存在"""
|
||||
|
||||
|
||||
def __init__(self, resource: str = "Resource"):
|
||||
super().__init__(
|
||||
message=f"{resource} not found",
|
||||
@@ -61,7 +63,7 @@ class ResourceNotFoundError(APIException):
|
||||
|
||||
class ValidationError(APIException):
|
||||
"""验证错误"""
|
||||
|
||||
|
||||
def __init__(self, message: str):
|
||||
super().__init__(
|
||||
message=message,
|
||||
@@ -100,12 +102,14 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
||||
"""请求验证异常处理"""
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
errors.append({
|
||||
"field": ".".join(str(loc) for loc in error["loc"]),
|
||||
"message": error["msg"],
|
||||
"type": error["type"],
|
||||
})
|
||||
|
||||
errors.append(
|
||||
{
|
||||
"field": ".".join(str(loc) for loc in error["loc"]),
|
||||
"message": error["msg"],
|
||||
"type": error["type"],
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={
|
||||
@@ -121,7 +125,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""通用异常处理"""
|
||||
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
||||
|
||||
|
||||
# 生产环境不返回详细错误信息
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
请求日志中间件
|
||||
"""
|
||||
import time
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
@@ -11,58 +13,57 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""请求日志中间件"""
|
||||
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# 记录请求开始时间
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
# 记录请求信息
|
||||
logger.info(f"Request: {request.method} {request.url.path}")
|
||||
|
||||
|
||||
# 处理请求
|
||||
response = await call_next(request)
|
||||
|
||||
|
||||
# 计算处理时间
|
||||
process_time = time.time() - start_time
|
||||
|
||||
|
||||
# 记录响应信息
|
||||
logger.info(
|
||||
f"Response: {request.method} {request.url.path} "
|
||||
f"status={response.status_code} time={process_time:.3f}s"
|
||||
f"Response: {request.method} {request.url.path} " f"status={response.status_code} time={process_time:.3f}s"
|
||||
)
|
||||
|
||||
|
||||
# 添加响应头
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""简单的速率限制中间件(基于内存)"""
|
||||
|
||||
|
||||
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
|
||||
super().__init__(app)
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self.requests = {} # {ip: [(timestamp, ...)]}
|
||||
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# 获取客户端 IP
|
||||
client_ip = request.client.host
|
||||
current_time = time.time()
|
||||
|
||||
|
||||
# 清理过期记录
|
||||
if client_ip in self.requests:
|
||||
self.requests[client_ip] = [
|
||||
ts for ts in self.requests[client_ip]
|
||||
if current_time - ts < self.window_seconds
|
||||
ts for ts in self.requests[client_ip] if current_time - ts < self.window_seconds
|
||||
]
|
||||
|
||||
|
||||
# 检查速率限制
|
||||
request_count = len(self.requests.get(client_ip, []))
|
||||
|
||||
|
||||
if request_count >= self.max_requests:
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
@@ -72,19 +73,17 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# 记录请求
|
||||
if client_ip not in self.requests:
|
||||
self.requests[client_ip] = []
|
||||
self.requests[client_ip].append(current_time)
|
||||
|
||||
|
||||
# 处理请求
|
||||
response = await call_next(request)
|
||||
|
||||
|
||||
# 添加速率限制信息到响应头
|
||||
response.headers["X-RateLimit-Limit"] = str(self.max_requests)
|
||||
response.headers["X-RateLimit-Remaining"] = str(
|
||||
self.max_requests - len(self.requests[client_ip])
|
||||
)
|
||||
|
||||
response.headers["X-RateLimit-Remaining"] = str(self.max_requests - len(self.requests[client_ip]))
|
||||
|
||||
return response
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""
|
||||
性能监控中间件
|
||||
"""
|
||||
import time
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
@@ -12,30 +14,30 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class PerformanceMonitoringMiddleware(BaseHTTPMiddleware):
|
||||
"""性能监控中间件"""
|
||||
|
||||
|
||||
def __init__(self, app, slow_request_threshold: float = 1.0):
|
||||
super().__init__(app)
|
||||
self.slow_request_threshold = slow_request_threshold # 慢请求阈值(秒)
|
||||
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable):
|
||||
# 记录请求开始时间
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
# 生成请求 ID
|
||||
request_id = self._generate_request_id()
|
||||
request.state.request_id = request_id
|
||||
|
||||
|
||||
# 处理请求
|
||||
try:
|
||||
response = await call_next(request)
|
||||
|
||||
|
||||
# 计算处理时间
|
||||
process_time = time.time() - start_time
|
||||
|
||||
|
||||
# 添加响应头
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
response.headers["X-Process-Time"] = f"{process_time:.3f}"
|
||||
|
||||
|
||||
# 记录慢请求
|
||||
if process_time > self.slow_request_threshold:
|
||||
logger.warning(
|
||||
@@ -43,55 +45,55 @@ class PerformanceMonitoringMiddleware(BaseHTTPMiddleware):
|
||||
f"took {process_time:.3f}s (threshold: {self.slow_request_threshold}s) "
|
||||
f"[request_id={request_id}]"
|
||||
)
|
||||
|
||||
|
||||
# 记录请求日志
|
||||
logger.info(
|
||||
f"{request.method} {request.url.path} "
|
||||
f"status={response.status_code} time={process_time:.3f}s "
|
||||
f"[request_id={request_id}]"
|
||||
)
|
||||
|
||||
|
||||
return response
|
||||
|
||||
|
||||
except Exception as e:
|
||||
process_time = time.time() - start_time
|
||||
logger.error(
|
||||
f"Request failed: {request.method} {request.url.path} "
|
||||
f"error={str(e)} time={process_time:.3f}s "
|
||||
f"[request_id={request_id}]",
|
||||
exc_info=True
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def _generate_request_id(self) -> str:
|
||||
"""生成请求 ID"""
|
||||
import uuid
|
||||
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class DatabaseQueryLogger:
|
||||
"""数据库查询日志记录器"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.queries = []
|
||||
self.total_time = 0
|
||||
|
||||
|
||||
def log_query(self, query: str, params: tuple, duration: float):
|
||||
"""记录查询"""
|
||||
self.queries.append({
|
||||
"query": query,
|
||||
"params": params,
|
||||
"duration": duration,
|
||||
})
|
||||
self.queries.append(
|
||||
{
|
||||
"query": query,
|
||||
"params": params,
|
||||
"duration": duration,
|
||||
}
|
||||
)
|
||||
self.total_time += duration
|
||||
|
||||
|
||||
# 记录慢查询(超过 100ms)
|
||||
if duration > 0.1:
|
||||
logger.warning(
|
||||
f"Slow query detected: {query[:100]}... "
|
||||
f"took {duration:.3f}s with params {params}"
|
||||
)
|
||||
|
||||
logger.warning(f"Slow query detected: {query[:100]}... " f"took {duration:.3f}s with params {params}")
|
||||
|
||||
def get_stats(self):
|
||||
"""获取统计信息"""
|
||||
return {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"""
|
||||
API 版本管理中间件
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class APIVersionMiddleware(BaseHTTPMiddleware):
|
||||
"""API 版本管理中间件"""
|
||||
|
||||
|
||||
# 版本配置
|
||||
VERSIONS = {
|
||||
"v1": {
|
||||
@@ -24,33 +26,31 @@ class APIVersionMiddleware(BaseHTTPMiddleware):
|
||||
"release_date": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# 提取版本号
|
||||
version = self._extract_version(request.url.path)
|
||||
|
||||
|
||||
# 处理请求
|
||||
response = await call_next(request)
|
||||
|
||||
|
||||
# 添加版本信息头
|
||||
if version:
|
||||
response.headers["X-API-Version"] = version
|
||||
|
||||
|
||||
# 添加弃用警告
|
||||
version_info = self.VERSIONS.get(version, {})
|
||||
if version_info.get("deprecated"):
|
||||
response.headers["X-API-Deprecated"] = "true"
|
||||
|
||||
|
||||
sunset_date = version_info.get("sunset_date")
|
||||
if sunset_date:
|
||||
response.headers["X-API-Sunset-Date"] = sunset_date
|
||||
|
||||
response.headers["X-API-Deprecation-Info"] = (
|
||||
f"https://docs.xiaoxia-saas.com/api/deprecation/{version}"
|
||||
)
|
||||
|
||||
|
||||
response.headers["X-API-Deprecation-Info"] = f"https://docs.xiaoxia-saas.com/api/deprecation/{version}"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _extract_version(self, path: str) -> str:
|
||||
"""从路径中提取版本号"""
|
||||
parts = path.split("/")
|
||||
@@ -62,14 +62,15 @@ class APIVersionMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
class VersionNotFoundMiddleware(BaseHTTPMiddleware):
|
||||
"""处理已下线的 API 版本"""
|
||||
|
||||
|
||||
SUNSET_VERSIONS = [] # 已下线的版本列表
|
||||
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
version = self._extract_version(request.url.path)
|
||||
|
||||
|
||||
if version in self.SUNSET_VERSIONS:
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
return JSONResponse(
|
||||
status_code=410,
|
||||
content={
|
||||
@@ -77,13 +78,13 @@ class VersionNotFoundMiddleware(BaseHTTPMiddleware):
|
||||
"code": "API_VERSION_SUNSET",
|
||||
"message": f"API {version} has been sunset and is no longer available",
|
||||
"sunset_date": "2028-07-01",
|
||||
"migration_guide": f"https://docs.xiaoxia-saas.com/api/migration/{version}"
|
||||
"migration_guide": f"https://docs.xiaoxia-saas.com/api/migration/{version}",
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def _extract_version(self, path: str) -> str:
|
||||
"""从路径中提取版本号"""
|
||||
parts = path.split("/")
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"""Schema package."""
|
||||
|
||||
from .asset import AssetResponse, CreateAssetRequest, ListAssetsResponse
|
||||
from .asset_library import AssetLibraryResponse, CreateAssetLibraryRequest, ListAssetLibrariesResponse
|
||||
from .asset_library import (
|
||||
AssetLibraryResponse,
|
||||
CreateAssetLibraryRequest,
|
||||
ListAssetLibrariesResponse,
|
||||
)
|
||||
from .health import HealthResponse
|
||||
from .ingest_job import IngestJobResponse, SubmitIngestJobRequest
|
||||
from .project import CreateProjectRequest, ListProjectsResponse, ProjectResponse
|
||||
|
||||
@@ -9,6 +9,21 @@ class CreateAssetRequest(BaseModel):
|
||||
storage_key: str = Field(..., min_length=1, max_length=255)
|
||||
mime_type: str = Field(..., min_length=1, max_length=100)
|
||||
metadata: dict[str, object] = Field(default_factory=dict)
|
||||
file_size: int = Field(default=0, ge=0)
|
||||
thumbnail_url: str | None = None
|
||||
duration: float | None = Field(default=None, ge=0)
|
||||
width: int | None = Field(default=None, ge=0)
|
||||
height: int | None = Field(default=None, ge=0)
|
||||
fps: float | None = Field(default=None, ge=0)
|
||||
codec: str | None = None
|
||||
status: str = Field(default="uploading")
|
||||
classification_status: str = Field(default="pending")
|
||||
quality_score: float | None = Field(default=None, ge=0, le=100)
|
||||
uploaded_by_user_id: str = Field(default="", max_length=100)
|
||||
|
||||
|
||||
class UpdateAssetReviewRequest(BaseModel):
|
||||
review_status: str = Field(..., pattern="^(pending_review|approved|rejected)$")
|
||||
|
||||
|
||||
class AssetResponse(BaseModel):
|
||||
@@ -20,6 +35,17 @@ class AssetResponse(BaseModel):
|
||||
storage_key: str
|
||||
mime_type: str
|
||||
metadata: dict[str, object]
|
||||
file_size: int
|
||||
thumbnail_url: str | None = None
|
||||
duration: float | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
fps: float | None = None
|
||||
codec: str | None = None
|
||||
status: str
|
||||
classification_status: str
|
||||
quality_score: float | None = None
|
||||
uploaded_by_user_id: str
|
||||
|
||||
|
||||
class ListAssetsResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AssetSmartViewItem(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
count: int
|
||||
description: str
|
||||
|
||||
|
||||
class AssetGapItem(BaseModel):
|
||||
key: str
|
||||
severity: str
|
||||
message: str
|
||||
recommendation: str
|
||||
|
||||
|
||||
class ProjectAssetDiagnosisResponse(BaseModel):
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
readiness_score: int
|
||||
readiness_label: str
|
||||
total_assets: int
|
||||
ready_assets: int
|
||||
video_assets: int
|
||||
image_assets: int
|
||||
voice_assets: int
|
||||
total_duration_seconds: float
|
||||
estimated_video_count: int
|
||||
used_assets: int
|
||||
unused_assets: int
|
||||
pending_review_assets: int
|
||||
smart_views: list[AssetSmartViewItem]
|
||||
gaps: list[AssetGapItem]
|
||||
@@ -5,7 +5,7 @@ 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)$")
|
||||
kind: str = Field(..., pattern="^(video|voice|image)$")
|
||||
|
||||
|
||||
class AssetLibraryResponse(BaseModel):
|
||||
@@ -14,6 +14,8 @@ class AssetLibraryResponse(BaseModel):
|
||||
project_id: str
|
||||
name: str
|
||||
kind: str
|
||||
asset_count: int
|
||||
total_size: int
|
||||
|
||||
|
||||
class ListAssetLibrariesResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EditTemplateResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
name: str
|
||||
description: str
|
||||
target_duration: float
|
||||
clip_count: int
|
||||
is_active: bool
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class EditPlanClipResponse(BaseModel):
|
||||
id: str
|
||||
asset_id: str
|
||||
asset_name: str
|
||||
sequence: int
|
||||
start_time: float
|
||||
duration: float
|
||||
reason: str
|
||||
|
||||
|
||||
class EditPlanResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
template_id: str
|
||||
asset_library_id: str
|
||||
title_id: str = ""
|
||||
status: str
|
||||
summary: str
|
||||
clips: list[EditPlanClipResponse] = Field(default_factory=list)
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class CreateEditPlanRequest(BaseModel):
|
||||
workspace_id: str
|
||||
asset_library_id: str
|
||||
template_id: str = ""
|
||||
title_id: str = ""
|
||||
@@ -0,0 +1,36 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
GeneratedVideoReviewStatus = Literal["pending_review", "approved", "rejected"]
|
||||
|
||||
|
||||
class UpdateGeneratedVideoReviewRequest(BaseModel):
|
||||
review_status: GeneratedVideoReviewStatus
|
||||
|
||||
|
||||
class GeneratedVideoResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
generation_task_id: str
|
||||
name: str
|
||||
file_url: str
|
||||
file_size: int
|
||||
duration: float
|
||||
thumbnail_url: str | None = None
|
||||
width: int
|
||||
height: int
|
||||
fps: float
|
||||
status: str = "completed"
|
||||
review_status: str = "pending_review"
|
||||
generation_params: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GeneratedVideoDownloadUrlResponse(BaseModel):
|
||||
video_id: str
|
||||
download_url: str
|
||||
|
||||
|
||||
class ListGeneratedVideosResponse(BaseModel):
|
||||
items: list[GeneratedVideoResponse]
|
||||
@@ -0,0 +1,25 @@
|
||||
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 = ""
|
||||
voice_library_id: str = ""
|
||||
edit_plan_id: str = ""
|
||||
created_by_user_id: str = ""
|
||||
|
||||
|
||||
class GenerationTaskResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
asset_library_id: str
|
||||
strategy_id: str
|
||||
voice_library_id: str
|
||||
edit_plan_id: str
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
error_message: str
|
||||
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
TitleCategory = Literal["default", "marketing", "tutorial", "story", "promo"]
|
||||
|
||||
|
||||
class ProjectTitleResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
text: str
|
||||
category: str
|
||||
favorite: bool
|
||||
usage_count: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ListProjectTitlesResponse(BaseModel):
|
||||
items: list[ProjectTitleResponse]
|
||||
|
||||
|
||||
class CreateProjectTitleRequest(BaseModel):
|
||||
workspace_id: str
|
||||
text: str = Field(min_length=1, max_length=200)
|
||||
category: TitleCategory = "default"
|
||||
favorite: bool = False
|
||||
|
||||
|
||||
class UpdateProjectTitleRequest(BaseModel):
|
||||
text: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
category: TitleCategory | None = None
|
||||
favorite: bool | None = None
|
||||
is_active: bool | None = None
|
||||
@@ -0,0 +1,23 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ProjectTaskResponse(BaseModel):
|
||||
id: str
|
||||
task_type: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
status: str
|
||||
progress: float
|
||||
current_step: str
|
||||
error_message: str = ""
|
||||
user_message: str = ""
|
||||
retryable: bool = False
|
||||
source_id: str = ""
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class ListProjectTasksResponse(BaseModel):
|
||||
items: list[ProjectTaskResponse] = Field(default_factory=list)
|
||||
@@ -5,3 +5,33 @@ class UploadAssetResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
url: str = Field(..., description="Public URL of uploaded file")
|
||||
|
||||
|
||||
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)
|
||||
content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100)
|
||||
file_size: int = Field(..., gt=0)
|
||||
|
||||
|
||||
class DirectUploadPrepareResponse(BaseModel):
|
||||
upload_url: str
|
||||
method: str
|
||||
storage_key: str
|
||||
expires_at: str
|
||||
fields: dict[str, str]
|
||||
max_size_bytes: int
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class DirectUploadCompleteResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
|
||||
+20
-6
@@ -1,8 +1,4 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
import os
|
||||
|
||||
from app.api.router import api_router, health_router
|
||||
from app.config import settings
|
||||
@@ -14,6 +10,12 @@ from app.middleware.exceptions import (
|
||||
validation_exception_handler,
|
||||
)
|
||||
from app.middleware.logging import RequestLoggingMiddleware
|
||||
from fastapi import FastAPI
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
app = FastAPI(
|
||||
title="小虾 SaaS API",
|
||||
@@ -41,6 +43,18 @@ app.add_middleware(RequestLoggingMiddleware)
|
||||
app.include_router(health_router)
|
||||
app.include_router(api_router)
|
||||
|
||||
_generated_files_dir = os.getenv("GENERATED_FILES_DIR", "/app/generated")
|
||||
os.makedirs(_generated_files_dir, exist_ok=True)
|
||||
app.mount(
|
||||
os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files"),
|
||||
StaticFiles(directory=_generated_files_dir),
|
||||
name="generated-files",
|
||||
)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
return app
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
@@ -54,4 +68,4 @@ async def root():
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
uvicorn.run(app, host=settings.API_HOST, port=settings.API_PORT)
|
||||
|
||||
@@ -1,50 +1,10 @@
|
||||
/**
|
||||
* 认证流程 E2E 测试
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Authentication Flow', () => {
|
||||
test('should complete login flow', async ({ page }) => {
|
||||
test.describe('Authentication page', () => {
|
||||
test('renders login form', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// 填写表单
|
||||
await page.fill('input[placeholder="邮箱"]', 'test@example.com');
|
||||
await page.fill('input[placeholder="密码"]', 'Password123!');
|
||||
|
||||
// 点击登录
|
||||
await page.click('button:has-text("登录")');
|
||||
|
||||
// 验证跳转到工作空间页面
|
||||
await expect(page).toHaveURL('/workspaces');
|
||||
await expect(page.locator('h1')).toContainText('工作空间');
|
||||
});
|
||||
|
||||
test('should complete registration flow', async ({ page }) => {
|
||||
await page.goto('/register');
|
||||
|
||||
// 填写注册表单
|
||||
await page.fill('input[placeholder="用户名"]', 'newuser');
|
||||
await page.fill('input[placeholder="邮箱"]', 'newuser@example.com');
|
||||
await page.fill('input[placeholder="密码"]', 'Password123!');
|
||||
await page.fill('input[placeholder="确认密码"]', 'Password123!');
|
||||
|
||||
// 点击注册
|
||||
await page.click('button:has-text("注册")');
|
||||
|
||||
// 验证成功消息
|
||||
await expect(page.locator('.ant-message-success')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle forgot password', async ({ page }) => {
|
||||
await page.goto('/forgot-password');
|
||||
|
||||
// 填写邮箱
|
||||
await page.fill('input[placeholder="邮箱"]', 'test@example.com');
|
||||
|
||||
// 点击发送
|
||||
await page.click('button:has-text("发送重置邮件")');
|
||||
|
||||
// 验证成功消息
|
||||
await expect(page.locator('text=重置邮件已发送')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('邮箱')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('密码')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /登\s*录/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const PASSWORD = 'SmokePass123!';
|
||||
const apiBase = process.env.E2E_API_BASE || '/api/v1';
|
||||
const apiOrigin = apiBase.endsWith('/api/v1') ? apiBase.slice(0, -'/api/v1'.length) : '';
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import('@playwright/test').Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}` });
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
type WorkspaceResponse = { id?: string; workspace_id?: string };
|
||||
type ProjectResponse = { id: string };
|
||||
type LibraryResponse = { id: string };
|
||||
type AssetListResponse = { items: Array<{ name: string; status: string; mime_type?: string; file_type?: string }> };
|
||||
type GenerationTaskResponse = { id: string; status: string; progress: number; result_count: number; error_message?: string | null; strategy_id?: string | null; edit_plan_id?: string | null };
|
||||
type ProjectTitleResponse = { id: string; text: string; usage_count: number };
|
||||
type GeneratedVideoResponse = { id: string; name: string; file_url: string; file_size: number };
|
||||
|
||||
test.describe('Core generation and download flow', () => {
|
||||
test('generates an MP4 from the browser and exposes a playable download', async ({ page, request }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-generation-${suffix}@example.com`;
|
||||
const username = `e2e_generation_${suffix}`;
|
||||
const libraryName = `E2E Generation Library ${suffix}`;
|
||||
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, username, password: PASSWORD, display_name: username },
|
||||
});
|
||||
expect(register.status(), await register.text()).toBe(201);
|
||||
|
||||
const registerData = (await register.json()) as { user_id: string };
|
||||
|
||||
const login = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password: PASSWORD },
|
||||
});
|
||||
expect(login.status(), await login.text()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
|
||||
const workspace = await request.post(`${apiBase}/workspaces`, {
|
||||
headers,
|
||||
data: { name: `E2E Generation Workspace ${suffix}` },
|
||||
});
|
||||
expect(workspace.status(), await workspace.text()).toBe(201);
|
||||
const workspaceData = (await workspace.json()) as WorkspaceResponse;
|
||||
const workspaceId = workspaceData.id || workspaceData.workspace_id;
|
||||
expect(workspaceId).toBeTruthy();
|
||||
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { workspace_id: workspaceId, name: `E2E Generation Project ${suffix}` },
|
||||
});
|
||||
expect(project.status(), await project.text()).toBe(200);
|
||||
const projectData = (await project.json()) as ProjectResponse;
|
||||
|
||||
const library = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: { workspace_id: workspaceId, project_id: projectData.id, name: libraryName, kind: 'video' },
|
||||
});
|
||||
expect(library.status(), await library.text()).toBe(200);
|
||||
const libraryData = (await library.json()) as LibraryResponse;
|
||||
|
||||
const projectTitleText = `E2E 生成标题 ${suffix}`;
|
||||
const title = await request.post(`${apiBase}/projects/${projectData.id}/titles`, {
|
||||
headers,
|
||||
data: { workspace_id: workspaceId, text: projectTitleText, category: 'marketing', favorite: true },
|
||||
});
|
||||
expect(title.status(), await title.text()).toBe(200);
|
||||
const titleData = (await title.json()) as ProjectTitleResponse;
|
||||
|
||||
const fixture = fs.readFileSync(path.join(currentDir, 'fixtures', 'sample.mp4'));
|
||||
const upload = await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
workspace_id: workspaceId || '',
|
||||
project_id: projectData.id,
|
||||
library_id: libraryData.id,
|
||||
file: {
|
||||
name: 'e2e-generation-source.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
buffer: fixture,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(upload.status(), await upload.text()).toBe(200);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const assets = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libraryData.id },
|
||||
});
|
||||
if (!assets.ok()) {
|
||||
return `http_${assets.status()}`;
|
||||
}
|
||||
const data = (await assets.json()) as AssetListResponse;
|
||||
const asset = data.items.find((item) => item.name === 'e2e-generation-source.mp4');
|
||||
return asset ? `${asset.mime_type || asset.file_type || ''}:${asset.status}` : 'missing';
|
||||
},
|
||||
{ timeout: 90_000, intervals: [1_000, 2_000, 3_000, 5_000] }
|
||||
)
|
||||
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/);
|
||||
|
||||
await page.addInitScript(
|
||||
({ token, user, projectId, workspaceId }) => {
|
||||
localStorage.setItem('access_token', token);
|
||||
localStorage.setItem('auth-storage', JSON.stringify({ state: { user, isAuthenticated: true }, version: 0 }));
|
||||
sessionStorage.setItem(`project-workspace:${projectId}`, workspaceId);
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
projectId: projectData.id,
|
||||
workspaceId,
|
||||
user: {
|
||||
id: registerData.user_id,
|
||||
user_id: registerData.user_id,
|
||||
email,
|
||||
username,
|
||||
display_name: username,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await page.goto(`/projects/${projectData.id}/generation`);
|
||||
await expect(page.getByText('剪辑参数')).toBeVisible({ timeout: 20_000 });
|
||||
await page.locator('.ant-select-selector').first().click();
|
||||
await page.getByText(`${libraryName} (video)`).click();
|
||||
await page.locator('.ant-select-selector').nth(1).click();
|
||||
await page.getByText(projectTitleText).click();
|
||||
|
||||
await expect(page.getByText(/素材就绪度:/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole('button', { name: '重新生成计划' })).toBeEnabled({ timeout: 20_000 });
|
||||
await page.getByRole('button', { name: '重新生成计划' }).click();
|
||||
await expect(page.getByText('剪辑计划预览')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/自动选择/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/e2e-generation-source\.mp4/)).toBeVisible({ timeout: 20_000 });
|
||||
const confirmGenerationButton = page.getByRole('complementary').getByRole('button', { name: '确认计划并生成' });
|
||||
await expect(confirmGenerationButton).toBeEnabled({ timeout: 20_000 });
|
||||
|
||||
const createTaskResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes('/api/v1/generation/tasks') && response.request().method() === 'POST',
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
await confirmGenerationButton.click();
|
||||
const createTaskResponse = await createTaskResponsePromise;
|
||||
expect(createTaskResponse.status(), await createTaskResponse.text()).toBe(200);
|
||||
const createdTask = (await createTaskResponse.json()) as GenerationTaskResponse;
|
||||
expect(createdTask.edit_plan_id || '').not.toBe('');
|
||||
|
||||
await expect(page.getByText(/生成状态:生成完成/)).toBeVisible({ timeout: 90_000 });
|
||||
await expect(page.getByText(/生成失败|生成任务加载失败|生成结果加载失败/)).toHaveCount(0);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const task = await request.get(`${apiBase}/generation/tasks/${createdTask.id}`, { headers });
|
||||
if (!task.ok()) {
|
||||
return `http_${task.status()}`;
|
||||
}
|
||||
const data = (await task.json()) as GenerationTaskResponse;
|
||||
return `${data.status}:${data.result_count}:${data.strategy_id || ''}:${data.error_message || ''}`;
|
||||
},
|
||||
{ timeout: 90_000, intervals: [1_000, 2_000, 5_000] }
|
||||
)
|
||||
.toMatch(new RegExp(`^completed:[1-9]\\d*:${titleData.id}:`));
|
||||
|
||||
const results = await request.get(`${apiBase}/generation/tasks/${createdTask.id}/results`, { headers });
|
||||
expect(results.status(), await results.text()).toBe(200);
|
||||
const resultsData = (await results.json()) as { items: GeneratedVideoResponse[] };
|
||||
expect(resultsData.items.length).toBeGreaterThan(0);
|
||||
const generatedVideo = resultsData.items[0];
|
||||
expect(generatedVideo.name).toMatch(/\.mp4$/);
|
||||
expect(generatedVideo.file_size).toBeGreaterThan(0);
|
||||
|
||||
await page.goto(`/projects/${projectData.id}/results`);
|
||||
const resultCard = page.locator('.xx-vertical-card').filter({ hasText: generatedVideo.name });
|
||||
await expect(resultCard).toBeVisible({ timeout: 20_000 });
|
||||
await expect(resultCard.getByText('待复核')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(resultCard.getByRole('button', { name: /下载/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '批量获取下载地址' })).toBeEnabled();
|
||||
await resultCard.getByRole('button', { name: '可发布' }).click();
|
||||
await expect(page.getByText('成片复核状态已更新')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('可发布', { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
const reviewedVideo = await request.get(`${apiBase}/generated-videos/${generatedVideo.id}`, { headers });
|
||||
expect(reviewedVideo.status(), await reviewedVideo.text()).toBe(200);
|
||||
const reviewedVideoData = (await reviewedVideo.json()) as { review_status: string; generation_params: Record<string, unknown> };
|
||||
expect(reviewedVideoData.review_status).toBe('approved');
|
||||
expect(reviewedVideoData.generation_params.title_id).toBe(titleData.id);
|
||||
expect(reviewedVideoData.generation_params.edit_plan_id).toBe(createdTask.edit_plan_id);
|
||||
const downloadUrlResponse = await request.get(`${apiBase}/generated-videos/${generatedVideo.id}/download-url`, { headers });
|
||||
expect(downloadUrlResponse.status(), await downloadUrlResponse.text()).toBe(200);
|
||||
const downloadData = (await downloadUrlResponse.json()) as { download_url: string };
|
||||
const videoResponse = await request.get(downloadData.download_url, { timeout: 30_000 });
|
||||
expect(videoResponse.status(), await videoResponse.text()).toBe(200);
|
||||
expect(videoResponse.headers()['content-type'] || '').toContain('video/mp4');
|
||||
const videoBody = await videoResponse.body();
|
||||
expect(videoBody.length).toBeGreaterThan(1024);
|
||||
|
||||
const assetsAfterGeneration = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libraryData.id },
|
||||
});
|
||||
expect(assetsAfterGeneration.status(), await assetsAfterGeneration.text()).toBe(200);
|
||||
const assetsAfterGenerationData = (await assetsAfterGeneration.json()) as { items: Array<{ name: string; metadata: Record<string, unknown> }> };
|
||||
const sourceAsset = assetsAfterGenerationData.items.find((item) => item.name === 'e2e-generation-source.mp4');
|
||||
expect(sourceAsset?.metadata.generation_use_count).toBe(1);
|
||||
expect(sourceAsset?.metadata.review_status).toBe('pending_review');
|
||||
const titleAfterGeneration = await request.get(`${apiBase}/projects/${projectData.id}/titles`, { headers });
|
||||
expect(titleAfterGeneration.status(), await titleAfterGeneration.text()).toBe(200);
|
||||
const titlesData = (await titleAfterGeneration.json()) as { items: ProjectTitleResponse[] };
|
||||
expect(titlesData.items.find((item) => item.id === titleData.id)?.usage_count).toBe(1);
|
||||
|
||||
await page.goto(`/projects/${projectData.id}/tasks`);
|
||||
await expect(page.getByText('项目任务中心')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText('视频生成')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText('已完成').first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(createdTask.id)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const PASSWORD = 'SmokePass123!';
|
||||
const apiBase = process.env.E2E_API_BASE || '/api/v1';
|
||||
const apiOrigin = apiBase.endsWith('/api/v1') ? apiBase.slice(0, -'/api/v1'.length) : '';
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import('@playwright/test').Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}` });
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('Project title library flow', () => {
|
||||
test('creates a reusable title from the browser', async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-title-${suffix}@example.com`;
|
||||
const username = `e2e_title_${suffix}`;
|
||||
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, username, password: PASSWORD, display_name: username },
|
||||
});
|
||||
expect(register.status(), await register.text()).toBe(201);
|
||||
|
||||
const registerData = (await register.json()) as { user_id: string };
|
||||
|
||||
const login = await request.post(`${apiBase}/auth/login`, { data: { email, password: PASSWORD } });
|
||||
expect(login.status(), await login.text()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
|
||||
const workspace = await request.post(`${apiBase}/workspaces`, {
|
||||
headers,
|
||||
data: { name: `E2E Title Workspace ${suffix}` },
|
||||
});
|
||||
expect(workspace.status(), await workspace.text()).toBe(201);
|
||||
const workspaceData = (await workspace.json()) as { id?: string; workspace_id?: string };
|
||||
const workspaceId = workspaceData.id || workspaceData.workspace_id;
|
||||
expect(workspaceId).toBeTruthy();
|
||||
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { workspace_id: workspaceId, name: `E2E Title Project ${suffix}`, description: 'Playwright title smoke' },
|
||||
});
|
||||
expect(project.status(), await project.text()).toBe(200);
|
||||
const projectData = (await project.json()) as { id: string };
|
||||
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem('access_token', token);
|
||||
localStorage.setItem('auth-storage', JSON.stringify({ state: { user, isAuthenticated: true }, version: 0 }));
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
user: {
|
||||
id: registerData.user_id,
|
||||
user_id: registerData.user_id,
|
||||
email,
|
||||
username,
|
||||
display_name: username,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await page.goto(`/projects/${projectData.id}/titles`);
|
||||
await expect(page.getByRole('heading', { name: '标题库' })).toBeVisible({ timeout: 20_000 });
|
||||
const titleText = `E2E 标题 ${suffix}`;
|
||||
await page.getByPlaceholder('例如:3 秒抓住注意力,30 秒讲清卖点').fill(titleText);
|
||||
const title = await request.post(`${apiBase}/projects/${projectData.id}/titles`, {
|
||||
headers,
|
||||
data: { workspace_id: workspaceId, text: titleText, category: 'default', favorite: true },
|
||||
});
|
||||
expect(title.status(), await title.text()).toBe(200);
|
||||
await page.reload();
|
||||
await expect(page.getByText(titleText)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator('.xx-title-row').filter({ hasText: titleText }).getByText('常用').first()).toBeVisible();
|
||||
await page.getByPlaceholder('搜索标题').fill(titleText);
|
||||
await expect(page.getByText(titleText)).toBeVisible();
|
||||
await expect(page.getByText('使用次数:0')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const PASSWORD = 'SmokePass123!';
|
||||
const apiBase = process.env.E2E_API_BASE || '/api/v1';
|
||||
const apiOrigin = apiBase.endsWith('/api/v1') ? apiBase.slice(0, -'/api/v1'.length) : '';
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import('@playwright/test').Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}` });
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
type WorkspaceResponse = { id?: string; workspace_id?: string };
|
||||
type ProjectResponse = { id: string };
|
||||
type LibraryResponse = { id: string };
|
||||
|
||||
test.describe('Core media upload flow', () => {
|
||||
test('uploads a MOV asset from the browser and shows it as ready', async ({ page, request }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-mov-${suffix}@example.com`;
|
||||
const username = `e2e_mov_${suffix}`;
|
||||
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: {
|
||||
email,
|
||||
username,
|
||||
password: PASSWORD,
|
||||
display_name: username,
|
||||
},
|
||||
});
|
||||
expect(register.status(), await register.text()).toBe(201);
|
||||
|
||||
const registerData = (await register.json()) as { user_id: string };
|
||||
|
||||
const login = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password: PASSWORD },
|
||||
});
|
||||
expect(login.status(), await login.text()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
|
||||
const workspace = await request.post(`${apiBase}/workspaces`, {
|
||||
headers,
|
||||
data: { name: `E2E Workspace ${suffix}` },
|
||||
});
|
||||
expect(workspace.status(), await workspace.text()).toBe(201);
|
||||
const workspaceData = (await workspace.json()) as WorkspaceResponse;
|
||||
const workspaceId = workspaceData.id || workspaceData.workspace_id;
|
||||
expect(workspaceId).toBeTruthy();
|
||||
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: {
|
||||
workspace_id: workspaceId,
|
||||
name: `E2E Project ${suffix}`,
|
||||
description: 'Playwright upload smoke',
|
||||
},
|
||||
});
|
||||
expect(project.status(), await project.text()).toBe(200);
|
||||
const projectData = (await project.json()) as ProjectResponse;
|
||||
|
||||
const library = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: {
|
||||
workspace_id: workspaceId,
|
||||
project_id: projectData.id,
|
||||
name: `E2E Video Library ${suffix}`,
|
||||
kind: 'video',
|
||||
},
|
||||
});
|
||||
expect(library.status(), await library.text()).toBe(200);
|
||||
const libraryData = (await library.json()) as LibraryResponse;
|
||||
|
||||
await page.addInitScript(
|
||||
({ token, user, projectId, workspaceId }) => {
|
||||
localStorage.setItem('access_token', token);
|
||||
localStorage.setItem('auth-storage', JSON.stringify({ state: { user, isAuthenticated: true }, version: 0 }));
|
||||
sessionStorage.setItem(`project-workspace:${projectId}`, workspaceId);
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
projectId: projectData.id,
|
||||
workspaceId,
|
||||
user: {
|
||||
id: registerData.user_id,
|
||||
user_id: registerData.user_id,
|
||||
email,
|
||||
username,
|
||||
display_name: username,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await page.goto(`/projects/${projectData.id}/assets`);
|
||||
await expect(page.getByText('点击或拖拽素材到这里上传')).toBeEnabled({ timeout: 20_000 });
|
||||
|
||||
const upload = await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
workspace_id: workspaceId || '',
|
||||
project_id: projectData.id,
|
||||
library_id: libraryData.id,
|
||||
file: {
|
||||
name: 'e2e-sample.MOV',
|
||||
mimeType: 'video/quicktime',
|
||||
buffer: Buffer.from('playwright mov upload smoke'),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(upload.status(), await upload.text()).toBe(200);
|
||||
|
||||
await expect(page.getByText(/上传失败|素材列表加载失败|素材库加载失败/)).toHaveCount(0, { timeout: 5_000 });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const assets = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libraryData.id },
|
||||
});
|
||||
if (!assets.ok()) {
|
||||
return `http_${assets.status()}`;
|
||||
}
|
||||
const data = (await assets.json()) as { items: Array<{ name: string; status: string; file_type?: string; mime_type?: string }> };
|
||||
const asset = data.items.find((item) => item.name === 'e2e-sample.MOV');
|
||||
return asset ? `${asset.mime_type || asset.file_type || ''}:${asset.status}` : 'missing';
|
||||
},
|
||||
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] }
|
||||
)
|
||||
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByText(/素材就绪度|Ready/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/预计成片|视频素材数量偏少|素材准备度良好/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText('e2e-sample.MOV', { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
await page.locator('.xx-vertical-card').filter({ hasText: 'e2e-sample.MOV' }).getByRole('button', { name: /通\s*过/ }).click();
|
||||
await expect(page.getByText('复核状态已更新')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(/已通过|approved/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/素材加载失败|上传失败/)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -1,34 +1,8 @@
|
||||
/**
|
||||
* 订阅流程 E2E 测试
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Subscription Flow', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 登录
|
||||
await page.goto('/login');
|
||||
await page.fill('input[placeholder="邮箱"]', 'test@example.com');
|
||||
await page.fill('input[placeholder="密码"]', 'Password123!');
|
||||
await page.click('button:has-text("登录")');
|
||||
await page.waitForURL('/workspaces');
|
||||
});
|
||||
|
||||
test('should view subscription plans', async ({ page }) => {
|
||||
test.describe('Subscription route guard', () => {
|
||||
test('redirects anonymous users to login', async ({ page }) => {
|
||||
await page.goto('/subscription');
|
||||
|
||||
// 验证套餐卡片
|
||||
await expect(page.locator('text=Free')).toBeVisible();
|
||||
await expect(page.locator('text=Pro')).toBeVisible();
|
||||
await expect(page.locator('text=Enterprise')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should start upgrade process', async ({ page }) => {
|
||||
await page.goto('/subscription');
|
||||
|
||||
// 点击升级按钮
|
||||
await page.click('button:has-text("立即升级") >> nth=0');
|
||||
|
||||
// 验证跳转到升级页面
|
||||
await expect(page).toHaveURL(/\/subscription\/upgrade\//);
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,61 +1,8 @@
|
||||
/**
|
||||
* 工作空间管理 E2E 测试
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Workspace Management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 登录
|
||||
await page.goto('/login');
|
||||
await page.fill('input[placeholder="邮箱"]', 'test@example.com');
|
||||
await page.fill('input[placeholder="密码"]', 'Password123!');
|
||||
await page.click('button:has-text("登录")');
|
||||
await page.waitForURL('/workspaces');
|
||||
});
|
||||
|
||||
test('should create workspace', async ({ page }) => {
|
||||
// 点击创建按钮
|
||||
await page.click('button:has-text("创建工作空间")');
|
||||
|
||||
// 填写表单
|
||||
await page.fill('input[placeholder="工作空间名称"]', 'Test Workspace');
|
||||
|
||||
// 提交
|
||||
await page.click('button:has-text("创建")');
|
||||
|
||||
// 验证创建成功
|
||||
await expect(page.locator('text=Test Workspace')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should view workspace details', async ({ page }) => {
|
||||
// 点击第一个工作空间
|
||||
await page.click('.ant-card >> nth=0');
|
||||
|
||||
// 验证详情页面
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
await expect(page.locator('.ant-tabs')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should invite member', async ({ page }) => {
|
||||
// 进入工作空间详情
|
||||
await page.click('.ant-card >> nth=0');
|
||||
|
||||
// 切换到成员标签
|
||||
await page.click('text=成员管理');
|
||||
|
||||
// 点击邀请按钮
|
||||
await page.click('button:has-text("邀请成员")');
|
||||
|
||||
// 填写邮箱
|
||||
await page.fill('input[placeholder="member@example.com"]', 'newmember@example.com');
|
||||
|
||||
// 选择角色
|
||||
await page.selectOption('select', 'member');
|
||||
|
||||
// 发送邀请
|
||||
await page.click('button:has-text("发送邀请")');
|
||||
|
||||
// 验证成功
|
||||
await expect(page.locator('.ant-message-success')).toBeVisible();
|
||||
test.describe('Workspace route guard', () => {
|
||||
test('redirects anonymous users to login', async ({ page }) => {
|
||||
await page.goto('/workspaces');
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+11
-11
@@ -1246,13 +1246,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
|
||||
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
|
||||
"version": "1.45.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.0.tgz",
|
||||
"integrity": "sha512-TVYsfMlGAaxeUllNkywbwek67Ncf8FRGn8ZlRdO291OL3NjG9oMbfVhyP82HQF0CZLMrYsvesqoUekxdWuF9Qw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.61.0"
|
||||
"playwright": "1.45.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -5037,13 +5037,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
||||
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||
"version": "1.45.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.0.tgz",
|
||||
"integrity": "sha512-4z3ac3plDfYzGB6r0Q3LF8POPR20Z8D0aXcxbJvmfMgSSq1hkcgvFRXJk9rUq5H/MJ0Ktal869hhOdI/zUTeLA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.0"
|
||||
"playwright-core": "1.45.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -5056,9 +5056,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||
"version": "1.45.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.0.tgz",
|
||||
"integrity": "sha512-lZmHlFQ0VYSpAs43dRq1/nJ9G/6SiTI7VPqidld9TDefL9tX87bTKExWZZUF5PeRyqtXqd8fQi2qmfIedkwsNQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
@@ -3,46 +3,57 @@
|
||||
*/
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const externalBaseURL = process.env.E2E_BASE_URL;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
reporter: [['html'], ['list']],
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
baseURL: externalBaseURL || 'http://localhost:3000',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: process.env.E2E_VIDEO ? 'retain-on-failure' : 'off',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
// 移动端测试
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
},
|
||||
],
|
||||
projects: process.env.E2E_ALL_BROWSERS
|
||||
? [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], channel: process.env.E2E_BROWSER_CHANNEL || 'msedge' },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], channel: process.env.E2E_BROWSER_CHANNEL || 'msedge' },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
webServer: externalBaseURL
|
||||
? undefined
|
||||
: {
|
||||
command: 'npm run dev -- --host 127.0.0.1 --port 3000',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
|
||||
+107
-2
@@ -12,6 +12,10 @@ export interface AssetItem {
|
||||
storage_key: string;
|
||||
mime_type: string;
|
||||
metadata: Record<string, unknown>;
|
||||
file_size?: number;
|
||||
status?: string;
|
||||
classification_status?: string | null;
|
||||
quality_score?: number | null;
|
||||
}
|
||||
|
||||
export interface AssetLibraryItem {
|
||||
@@ -19,7 +23,9 @@ export interface AssetLibraryItem {
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
kind: 'video' | 'voice';
|
||||
kind: 'video' | 'voice' | 'image';
|
||||
asset_count?: number;
|
||||
total_size?: number;
|
||||
}
|
||||
|
||||
export interface IngestJob {
|
||||
@@ -44,6 +50,30 @@ export interface ClassificationJob {
|
||||
error_message: string;
|
||||
}
|
||||
|
||||
export interface ProjectAssetDiagnosis {
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
readiness_score: number;
|
||||
readiness_label: string;
|
||||
total_assets: number;
|
||||
ready_assets: number;
|
||||
video_assets: number;
|
||||
image_assets: number;
|
||||
voice_assets: number;
|
||||
total_duration_seconds: number;
|
||||
estimated_video_count: number;
|
||||
used_assets: number;
|
||||
unused_assets: number;
|
||||
pending_review_assets: number;
|
||||
smart_views: Array<{ key: string; label: string; count: number; description: string }>;
|
||||
gaps: Array<{ key: string; severity: 'critical' | 'warning' | 'info'; message: string; recommendation: string }>;
|
||||
}
|
||||
|
||||
export const getProjectAssetDiagnosis = async (projectId: string): Promise<ProjectAssetDiagnosis> => {
|
||||
const response = await apiClient.get(`/projects/${projectId}/asset-diagnosis`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getAssetLibraries = async (projectId: string): Promise<AssetLibraryItem[]> => {
|
||||
const response = await apiClient.get('/asset-libraries', {
|
||||
params: { project_id: projectId },
|
||||
@@ -55,7 +85,7 @@ export const createAssetLibrary = async (data: {
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
kind: 'video' | 'voice';
|
||||
kind: 'video' | 'voice' | 'image';
|
||||
}): Promise<AssetLibraryItem> => {
|
||||
const response = await apiClient.post('/asset-libraries', data);
|
||||
return response.data;
|
||||
@@ -68,15 +98,90 @@ export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const updateAssetReviewStatus = async (
|
||||
assetId: string,
|
||||
reviewStatus: 'pending_review' | 'approved' | 'rejected'
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.patch(`/assets/${assetId}/review`, {
|
||||
review_status: reviewStatus,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const uploadAsset = async (
|
||||
formData: FormData
|
||||
): Promise<{ storage_key: string; ingest_job_id: string; url: string }> => {
|
||||
const response = await apiClient.post('/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 30 * 60 * 1000,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const prepareDirectUpload = async (data: {
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
file_size: number;
|
||||
}): Promise<{
|
||||
upload_url: string;
|
||||
method: string;
|
||||
storage_key: string;
|
||||
expires_at: string;
|
||||
fields: Record<string, string>;
|
||||
max_size_bytes: number;
|
||||
}> => {
|
||||
const response = await apiClient.post('/upload/direct/prepare', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const completeDirectUpload = async (data: {
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
storage_key: string;
|
||||
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
|
||||
const response = await apiClient.post('/upload/direct/complete', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const uploadAssetDirect = async (data: {
|
||||
file: File;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
|
||||
const prepared = await prepareDirectUpload({
|
||||
workspace_id: data.workspace_id,
|
||||
project_id: data.project_id,
|
||||
library_id: data.library_id,
|
||||
filename: data.file.name,
|
||||
content_type: data.file.type || 'application/octet-stream',
|
||||
file_size: data.file.size,
|
||||
});
|
||||
|
||||
const directForm = new FormData();
|
||||
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value));
|
||||
directForm.append('file', data.file);
|
||||
|
||||
const uploadResponse = await fetch(prepared.upload_url, {
|
||||
method: prepared.method,
|
||||
body: directForm,
|
||||
});
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`OSS direct upload failed: ${uploadResponse.status}`);
|
||||
}
|
||||
|
||||
return completeDirectUpload({
|
||||
workspace_id: data.workspace_id,
|
||||
project_id: data.project_id,
|
||||
library_id: data.library_id,
|
||||
storage_key: prepared.storage_key,
|
||||
});
|
||||
};
|
||||
|
||||
export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
|
||||
const response = await apiClient.get(`/ingest-jobs/${jobId}`);
|
||||
return response.data;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeUser } from './auth';
|
||||
|
||||
describe('normalizeUser', () => {
|
||||
it('normalizes canonical API current-user fields', () => {
|
||||
expect(
|
||||
normalizeUser({
|
||||
user_id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
username: 'user',
|
||||
display_name: 'User',
|
||||
email_verified: true,
|
||||
})
|
||||
).toEqual({
|
||||
id: 'user-1',
|
||||
user_id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
username: 'user',
|
||||
display_name: 'User',
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
created_at: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps compatibility with legacy UI-shaped user fields', () => {
|
||||
expect(
|
||||
normalizeUser({
|
||||
id: 'user-2',
|
||||
email: 'legacy@example.com',
|
||||
username: 'legacy',
|
||||
display_name: 'Legacy',
|
||||
is_email_verified: false,
|
||||
created_at: '2026-06-22T00:00:00Z',
|
||||
})
|
||||
).toEqual({
|
||||
id: 'user-2',
|
||||
user_id: 'user-2',
|
||||
email: 'legacy@example.com',
|
||||
username: 'legacy',
|
||||
display_name: 'Legacy',
|
||||
is_email_verified: false,
|
||||
email_verified: false,
|
||||
created_at: '2026-06-22T00:00:00Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,9 +11,13 @@ export interface LoginRequest {
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
refresh_token?: string | null;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
@@ -25,13 +29,42 @@ export interface RegisterRequest {
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
is_email_verified: boolean;
|
||||
created_at: string;
|
||||
email_verified: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id?: string;
|
||||
user_id?: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
is_email_verified?: boolean;
|
||||
email_verified?: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export const normalizeUser = (data: UserResponse): User => {
|
||||
const userId = data.id ?? data.user_id ?? '';
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false;
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
user_id: userId,
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
is_email_verified: emailVerified,
|
||||
email_verified: emailVerified,
|
||||
created_at: data.created_at,
|
||||
};
|
||||
};
|
||||
|
||||
// 登录
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post('/auth/login', data);
|
||||
@@ -51,8 +84,8 @@ export const logout = async (): Promise<void> => {
|
||||
|
||||
// 获取当前用户
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get('/auth/me');
|
||||
return response.data;
|
||||
const response = await apiClient.get<UserResponse>('/auth/me');
|
||||
return normalizeUser(response.data);
|
||||
};
|
||||
|
||||
// 请求密码重置
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 封装 Axios 实例,配置拦截器和 Token 管理
|
||||
*/
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
// 创建 Axios 实例
|
||||
const apiClient = axios.create({
|
||||
@@ -27,41 +28,12 @@ apiClient.interceptors.request.use(
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器:处理错误和 Token 刷新
|
||||
// 响应拦截器:处理未授权状态
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Token 过期,尝试刷新
|
||||
if (error.response?.status === 401 && originalRequest) {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const { data } = await axios.post('/api/v1/auth/refresh', {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
// 保存新 Token
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
|
||||
// 重试原请求
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
|
||||
}
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,清除 Token 并跳转登录
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
} else {
|
||||
// 没有 refresh token,直接跳转登录
|
||||
window.location.href = '/login';
|
||||
}
|
||||
if (error.response?.status === 401) {
|
||||
useAuthStore.getState().clearAuth();
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface EditTemplateItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
target_duration: number;
|
||||
clip_count: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface EditPlanClipItem {
|
||||
id: string;
|
||||
asset_id: string;
|
||||
asset_name: string;
|
||||
sequence: number;
|
||||
start_time: number;
|
||||
duration: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface EditPlanItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
template_id: string;
|
||||
asset_library_id: string;
|
||||
title_id: string;
|
||||
status: string;
|
||||
summary: string;
|
||||
clips: EditPlanClipItem[];
|
||||
}
|
||||
|
||||
export const getEditTemplates = async (projectId: string, workspaceId: string): Promise<EditTemplateItem[]> => {
|
||||
const response = await apiClient.get(`/projects/${projectId}/edit-plans/templates`, { params: { workspace_id: workspaceId } });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createEditPlan = async ({ projectId, data }: { projectId: string; data: {
|
||||
workspace_id: string;
|
||||
asset_library_id: string;
|
||||
template_id?: string;
|
||||
title_id?: string;
|
||||
} }): Promise<EditPlanItem> => {
|
||||
const response = await apiClient.post(`/projects/${projectId}/edit-plans`, data);
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface GenerationTaskItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
asset_library_id: string;
|
||||
strategy_id?: string | null;
|
||||
voice_library_id?: string | null;
|
||||
edit_plan_id?: string | null;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
progress: number;
|
||||
result_count: number;
|
||||
error_message?: string | null;
|
||||
}
|
||||
|
||||
export interface GeneratedVideoItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
generation_task_id: string;
|
||||
name: string;
|
||||
file_url: string;
|
||||
file_size: number;
|
||||
duration: number;
|
||||
thumbnail_url?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
status?: string;
|
||||
review_status?: 'pending_review' | 'approved' | 'rejected';
|
||||
generation_params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const createGenerationTask = async (data: {
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
asset_library_id: string;
|
||||
strategy_id?: string;
|
||||
voice_library_id?: string;
|
||||
edit_plan_id?: string;
|
||||
created_by_user_id?: string;
|
||||
}): Promise<GenerationTaskItem> => {
|
||||
const response = await apiClient.post('/generation/tasks', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getGenerationTask = async (taskId: string): Promise<GenerationTaskItem> => {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getGenerationResults = async (taskId: string): Promise<GeneratedVideoItem[]> => {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`);
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const getGeneratedVideos = async (projectId: string): Promise<GeneratedVideoItem[]> => {
|
||||
const response = await apiClient.get('/generated-videos', { params: { project_id: projectId } });
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const updateGeneratedVideoReviewStatus = async (videoId: string, reviewStatus: 'pending_review' | 'approved' | 'rejected'): Promise<GeneratedVideoItem> => {
|
||||
const response = await apiClient.patch(`/generated-videos/${videoId}/review`, { review_status: reviewStatus });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getGeneratedVideoDownloadUrl = async (videoId: string): Promise<string> => {
|
||||
const response = await apiClient.get(`/generated-videos/${videoId}/download-url`);
|
||||
return response.data.download_url;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface ProjectTitleItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
text: string;
|
||||
category: 'default' | 'marketing' | 'tutorial' | 'story' | 'promo';
|
||||
favorite: boolean;
|
||||
usage_count: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const getProjectTitles = async (projectId: string, activeOnly = false): Promise<ProjectTitleItem[]> => {
|
||||
const response = await apiClient.get(`/projects/${projectId}/titles`, { params: { active_only: activeOnly } });
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const createProjectTitle = async (projectId: string, data: { workspace_id: string; text: string; category?: ProjectTitleItem['category']; favorite?: boolean }): Promise<ProjectTitleItem> => {
|
||||
const response = await apiClient.post(`/projects/${projectId}/titles`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateProjectTitle = async (titleId: string, data: { text?: string; category?: ProjectTitleItem['category']; favorite?: boolean; is_active?: boolean }): Promise<ProjectTitleItem> => {
|
||||
const response = await apiClient.patch(`/project-titles/${titleId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface ProjectItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CreateProjectRequest {
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const getProject = async (projectId: string): Promise<ProjectItem> => {
|
||||
const response = await apiClient.get(`/projects/${projectId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getProjects = async (workspaceId: string): Promise<ProjectItem[]> => {
|
||||
const response = await apiClient.get('/projects', { params: { workspace_id: workspaceId } });
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const createProject = async (data: CreateProjectRequest): Promise<ProjectItem> => {
|
||||
const response = await apiClient.post('/projects', data);
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface ProjectTaskItem {
|
||||
id: string;
|
||||
task_type: 'ingest' | 'generation' | string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
current_step: string;
|
||||
error_message: string;
|
||||
user_message: string;
|
||||
retryable: boolean;
|
||||
source_id: string;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export const getProjectTasks = async (projectId: string): Promise<ProjectTaskItem[]> => {
|
||||
const response = await apiClient.get(`/projects/${projectId}/tasks`);
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const retryProjectTask = async (taskType: string, sourceId: string): Promise<ProjectTaskItem> => {
|
||||
const response = await apiClient.post(`/tasks/${taskType}/${sourceId}/retry`);
|
||||
return response.data;
|
||||
};
|
||||
@@ -5,11 +5,17 @@ import apiClient from './client';
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
owner_user_id: string;
|
||||
owner_user_id?: string;
|
||||
subscription_plan: 'free' | 'pro' | 'enterprise';
|
||||
subscription_status: 'active' | 'inactive' | 'expired';
|
||||
created_at: string;
|
||||
created_at?: string;
|
||||
max_projects?: number;
|
||||
max_storage_gb?: number;
|
||||
used_storage_gb?: number;
|
||||
member_count?: number;
|
||||
user_role?: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkspaceRequest {
|
||||
@@ -19,9 +25,11 @@ export interface CreateWorkspaceRequest {
|
||||
|
||||
export interface WorkspaceMember {
|
||||
id: string;
|
||||
member_id: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name?: string;
|
||||
role: 'owner' | 'admin' | 'member' | 'viewer';
|
||||
joined_at: string;
|
||||
}
|
||||
@@ -31,32 +39,72 @@ export interface InviteMemberRequest {
|
||||
role: 'admin' | 'member' | 'viewer';
|
||||
}
|
||||
|
||||
export interface InviteMemberResponse {
|
||||
invitation_id: string;
|
||||
invitee_email: string;
|
||||
role: 'admin' | 'member' | 'viewer';
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
type WorkspaceApiResponse = Omit<Workspace, 'id' | 'workspace_id'> & {
|
||||
id?: string;
|
||||
workspace_id?: string;
|
||||
};
|
||||
|
||||
type WorkspaceMemberApiResponse = Omit<WorkspaceMember, 'id' | 'member_id'> & {
|
||||
id?: string;
|
||||
member_id?: string;
|
||||
};
|
||||
|
||||
const normalizeWorkspace = (workspace: WorkspaceApiResponse): Workspace => {
|
||||
const workspaceId = workspace.id || workspace.workspace_id || '';
|
||||
return {
|
||||
...workspace,
|
||||
id: workspaceId,
|
||||
workspace_id: workspaceId,
|
||||
subscription_status: workspace.subscription_status || 'active',
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeMember = (member: WorkspaceMemberApiResponse): WorkspaceMember => {
|
||||
const memberId = member.member_id || member.id || '';
|
||||
return {
|
||||
...member,
|
||||
id: member.user_id || memberId,
|
||||
member_id: memberId,
|
||||
};
|
||||
};
|
||||
|
||||
export const getWorkspaces = async (): Promise<Workspace[]> => {
|
||||
const response = await apiClient.get('/workspaces');
|
||||
return response.data;
|
||||
const payload = response.data;
|
||||
const workspaces = Array.isArray(payload) ? payload : payload.workspaces || [];
|
||||
return workspaces.map(normalizeWorkspace);
|
||||
};
|
||||
|
||||
export const getWorkspace = async (id: string): Promise<Workspace> => {
|
||||
const response = await apiClient.get(`/workspaces/${id}`);
|
||||
return response.data;
|
||||
return normalizeWorkspace(response.data);
|
||||
};
|
||||
|
||||
export const createWorkspace = async (
|
||||
data: CreateWorkspaceRequest
|
||||
): Promise<Workspace> => {
|
||||
const response = await apiClient.post('/workspaces', data);
|
||||
return response.data;
|
||||
return normalizeWorkspace(response.data);
|
||||
};
|
||||
|
||||
export const getMembers = async (workspaceId: string): Promise<WorkspaceMember[]> => {
|
||||
const response = await apiClient.get(`/workspaces/${workspaceId}/members`);
|
||||
return response.data;
|
||||
const payload = response.data;
|
||||
const members = Array.isArray(payload) ? payload : payload.members || [];
|
||||
return members.map(normalizeMember);
|
||||
};
|
||||
|
||||
export const inviteMember = async (
|
||||
workspaceId: string,
|
||||
data: InviteMemberRequest
|
||||
): Promise<{ message: string }> => {
|
||||
): Promise<InviteMemberResponse> => {
|
||||
const response = await apiClient.post(`/workspaces/${workspaceId}/members/invite`, data);
|
||||
return response.data;
|
||||
};
|
||||
@@ -64,23 +112,21 @@ export const inviteMember = async (
|
||||
export const removeMember = async (
|
||||
workspaceId: string,
|
||||
memberId: string
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.delete(`/workspaces/${workspaceId}/members/${memberId}`);
|
||||
return response.data;
|
||||
): Promise<void> => {
|
||||
await apiClient.delete(`/workspaces/${workspaceId}/members/${memberId}`);
|
||||
};
|
||||
|
||||
export const updateMemberRole = async (
|
||||
workspaceId: string,
|
||||
memberId: string,
|
||||
role: 'admin' | 'member' | 'viewer'
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.patch(`/workspaces/${workspaceId}/members/${memberId}`, {
|
||||
): Promise<{ user_id: string; old_role: string; new_role: string }> => {
|
||||
const response = await apiClient.patch(`/workspaces/${workspaceId}/members/${memberId}/role`, {
|
||||
role,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const leaveWorkspace = async (workspaceId: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post(`/workspaces/${workspaceId}/leave`);
|
||||
return response.data;
|
||||
export const leaveWorkspace = async (_workspaceId: string): Promise<{ message: string }> => {
|
||||
throw new Error('主动退出工作空间暂未开放');
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Table, Button, Tag, Space, Popconfirm, message, Select } from 'antd';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
import { useWorkspaceMembers } from '@/hooks/useWorkspace';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { removeMember, updateMemberRole } from '@/api/workspace';
|
||||
|
||||
@@ -4,7 +4,18 @@
|
||||
import React from 'react';
|
||||
import { Card, Progress, Row, Col, Tag, Space } from 'antd';
|
||||
import { ProjectOutlined, CloudOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import type { QuotaStatus } from '@/api/subscription';
|
||||
interface QuotaStatus {
|
||||
projects: {
|
||||
used: number;
|
||||
limit: number;
|
||||
status: string;
|
||||
};
|
||||
storage: {
|
||||
used_gb: number;
|
||||
limit_gb: number;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface QuotaDisplayProps {
|
||||
quota: QuotaStatus;
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
/**
|
||||
* 顶部导航栏
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Layout, Button, Dropdown, Avatar, Space } from 'antd';
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Avatar, Dropdown, Space } from 'antd';
|
||||
import { LogoutOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLogout } from '@/hooks/useAuth';
|
||||
import type { MenuProps } from 'antd';
|
||||
|
||||
const { Header: AntHeader } = Layout;
|
||||
const extractProjectId = (pathname: string) => {
|
||||
const match = pathname.match(/^\/projects\/([^/]+)/);
|
||||
return match?.[1] || '';
|
||||
};
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const sidebarCollapsed = useUIStore((state) => state.sidebarCollapsed);
|
||||
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
|
||||
const location = useLocation();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const logoutMutation = useLogout();
|
||||
const projectId = extractProjectId(location.pathname);
|
||||
|
||||
const navItems = useMemo(() => {
|
||||
const projectNav = projectId
|
||||
? [
|
||||
{ key: 'assets', label: '素材库', path: `/projects/${projectId}/assets` },
|
||||
{ key: 'titles', label: '标题库', path: `/projects/${projectId}/titles` },
|
||||
{ key: 'voices', label: '配音库', path: `/projects/${projectId}/voices` },
|
||||
{ key: 'generation', label: '视频剪辑', path: `/projects/${projectId}/generation` },
|
||||
{ key: 'results', label: '成片库', path: `/projects/${projectId}/results` },
|
||||
]
|
||||
: [];
|
||||
|
||||
return [
|
||||
{ key: 'home', label: '首页', path: '/workspaces' },
|
||||
{ key: 'subscription', label: '订阅', path: '/subscription' },
|
||||
...projectNav,
|
||||
];
|
||||
}, [projectId]);
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
@@ -38,42 +49,49 @@ const Header: React.FC = () => {
|
||||
label: '账号设置',
|
||||
onClick: () => navigate('/profile/settings'),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: () => logoutMutation.mutate(),
|
||||
onClick: () => logoutMutation.mutateAsync(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AntHeader
|
||||
style={{
|
||||
padding: '0 24px',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={toggleSidebar}
|
||||
style={{ fontSize: '16px', width: 64, height: 64 }}
|
||||
/>
|
||||
<header className="xx-top-nav">
|
||||
<div className="xx-top-nav-inner">
|
||||
<button className="xx-brand" type="button" onClick={() => navigate('/workspaces')}>
|
||||
<span className="xx-logo">🦐</span>
|
||||
<span>小虾自动剪辑</span>
|
||||
</button>
|
||||
|
||||
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
|
||||
<Space style={{ cursor: 'pointer' }}>
|
||||
<Avatar icon={<UserOutlined />} />
|
||||
<span>{user?.display_name || user?.username}</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</AntHeader>
|
||||
<nav className="xx-nav-links">
|
||||
{navItems.map((item) => {
|
||||
const active = item.path === '/workspaces'
|
||||
? location.pathname === '/' || location.pathname.startsWith('/workspaces')
|
||||
: location.pathname.startsWith(item.path);
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
className={active ? 'active' : ''}
|
||||
type="button"
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
|
||||
<Space className="xx-user-menu">
|
||||
<Avatar className="xx-avatar" icon={<UserOutlined />} />
|
||||
<span>{user?.display_name || user?.username || '小虾用户'}</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,860 @@
|
||||
.main-layout {
|
||||
min-height: 100vh;
|
||||
:root {
|
||||
--xx-primary: #4f46e5;
|
||||
--xx-primary-dark: #4338ca;
|
||||
--xx-primary-soft: #eef2ff;
|
||||
--xx-slate: #0f172a;
|
||||
--xx-muted: #64748b;
|
||||
--xx-line: #e2e8f0;
|
||||
--xx-bg: #f8fafc;
|
||||
--xx-card: #ffffff;
|
||||
--xx-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
|
||||
--xx-shadow-soft: 0 12px 34px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.main-layout-content {
|
||||
margin: 24px 16px;
|
||||
padding: 24px;
|
||||
.xx-app-shell {
|
||||
min-height: 100vh;
|
||||
color: var(--xx-slate);
|
||||
background:
|
||||
radial-gradient(circle at 18% 20%, rgba(79, 70, 229, 0.11), transparent 30%),
|
||||
radial-gradient(circle at 86% 14%, rgba(16, 185, 129, 0.08), transparent 28%),
|
||||
radial-gradient(circle at 48% 80%, rgba(99, 102, 241, 0.08), transparent 34%),
|
||||
var(--xx-bg);
|
||||
}
|
||||
|
||||
.xx-top-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
min-height: 68px;
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.92);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.04);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.xx-top-nav-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
max-width: 1440px;
|
||||
min-height: 68px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.xx-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--xx-slate);
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-logo {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #818cf8, #4f46e5);
|
||||
box-shadow: 0 12px 26px rgba(79, 70, 229, 0.24);
|
||||
}
|
||||
|
||||
.xx-nav-links {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
gap: 22px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xx-nav-links button {
|
||||
position: relative;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--xx-muted);
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.xx-nav-links button.active,
|
||||
.xx-nav-links button:hover {
|
||||
color: var(--xx-primary);
|
||||
}
|
||||
|
||||
.xx-nav-links button.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -22px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--xx-primary);
|
||||
}
|
||||
|
||||
.xx-user-menu {
|
||||
cursor: pointer;
|
||||
color: #334155;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.xx-avatar {
|
||||
background: var(--xx-primary-soft);
|
||||
color: var(--xx-primary-dark);
|
||||
}
|
||||
|
||||
.xx-app-content {
|
||||
width: min(1440px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
padding: 38px 0 76px;
|
||||
}
|
||||
|
||||
.xx-page-card {
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: var(--xx-shadow);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.xx-page-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.xx-page-head h1,
|
||||
.xx-page-head h2 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--xx-slate);
|
||||
font-size: 28px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.xx-page-head p {
|
||||
margin: 0;
|
||||
color: var(--xx-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.xx-action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.xx-btn-primary.ant-btn-primary {
|
||||
border: 0;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22);
|
||||
}
|
||||
|
||||
.xx-vertical-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-vertical-card {
|
||||
border: 1px solid #e8eef7;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, #fff, #fbfdff);
|
||||
padding: 9px;
|
||||
transition: 0.18s ease;
|
||||
}
|
||||
|
||||
.xx-vertical-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--xx-shadow-soft);
|
||||
}
|
||||
|
||||
.xx-vertical-thumb {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: 9 / 16;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #0ea5e9, #312e81);
|
||||
box-shadow: inset 0 -50px 80px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.xx-vertical-thumb.orange { background: linear-gradient(135deg, #f97316, #ef4444); }
|
||||
.xx-vertical-thumb.gray { background: linear-gradient(135deg, #64748b, #334155); }
|
||||
.xx-vertical-thumb.green { background: linear-gradient(135deg, #10b981, #0284c7); }
|
||||
|
||||
.xx-play {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.xx-vertical-title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-vertical-title b {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-vertical-meta {
|
||||
margin: 0;
|
||||
color: var(--xx-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.xx-empty-state {
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, #fff, #f8fafc);
|
||||
color: var(--xx-muted);
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-empty-icon {
|
||||
display: grid;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
place-items: center;
|
||||
margin: 0 auto 12px;
|
||||
border-radius: 18px;
|
||||
background: var(--xx-primary-soft);
|
||||
color: var(--xx-primary-dark);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.xx-empty-state b {
|
||||
display: block;
|
||||
color: var(--xx-slate);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.xx-vertical-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.xx-top-nav-inner {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.xx-nav-links {
|
||||
order: 3;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
gap: 14px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.xx-nav-links button.active::after { display: none; }
|
||||
.xx-app-content { width: calc(100% - 28px); padding-top: 22px; }
|
||||
.xx-page-head { flex-direction: column; }
|
||||
.xx-action-row,
|
||||
.xx-action-row .ant-btn { width: 100%; }
|
||||
.xx-vertical-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.xx-brand { font-size: 18px; }
|
||||
.xx-page-card { border-radius: 22px; padding: 16px; }
|
||||
.xx-vertical-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.xx-vertical-card { padding: 8px; }
|
||||
.xx-vertical-title b { font-size: 12px; }
|
||||
.xx-vertical-meta { font-size: 10px; }
|
||||
}
|
||||
|
||||
.xx-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--xx-line);
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
min-height: 280px;
|
||||
padding: 13px;
|
||||
transition: 0.18s ease;
|
||||
}
|
||||
|
||||
.xx-title-row:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--xx-shadow-soft);
|
||||
}
|
||||
|
||||
.xx-title-index {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
background: var(--xx-primary-soft);
|
||||
color: var(--xx-primary-dark);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.xx-title-row b {
|
||||
display: block;
|
||||
color: var(--xx-slate);
|
||||
}
|
||||
|
||||
.xx-title-row span {
|
||||
display: block;
|
||||
color: var(--xx-muted);
|
||||
font-size: 12px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.xx-generation-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.xx-section-block {
|
||||
border-top: 1px solid #edf2f7;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.xx-section-block:first-child {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.xx-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.xx-section-title h3 {
|
||||
margin: 0;
|
||||
color: var(--xx-slate);
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.xx-plan-panel {
|
||||
border: 1px solid #e8eef7;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
padding: 18px;
|
||||
align-self: start;
|
||||
box-shadow: var(--xx-shadow-soft);
|
||||
}
|
||||
|
||||
.xx-preview-phone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 360px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 22px;
|
||||
padding: 18px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #111827, #312e81);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-preview-phone div {
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,.92);
|
||||
color: #111827;
|
||||
font-weight: 850;
|
||||
text-align: center;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.xx-generation-layout { grid-template-columns: 1fr; }
|
||||
.xx-plan-panel { order: -1; }
|
||||
.xx-preview-phone { max-height: 300px; }
|
||||
}
|
||||
|
||||
.xx-vertical-card.selected {
|
||||
border-color: var(--xx-primary);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.14), var(--xx-shadow-soft);
|
||||
}
|
||||
|
||||
.xx-kpi-card {
|
||||
border: 1px solid var(--xx-line);
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, #fff, #f8fafc);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.xx-kpi-card b {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
color: var(--xx-slate);
|
||||
}
|
||||
|
||||
.xx-kpi-card span {
|
||||
color: var(--xx-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.xx-voice-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.xx-voice-card {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr) 42px;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--xx-line);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, #fff, #fbfdff);
|
||||
padding: 14px;
|
||||
transition: 0.18s ease;
|
||||
}
|
||||
|
||||
.xx-voice-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--xx-shadow-soft);
|
||||
}
|
||||
|
||||
.xx-voice-cover {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
place-items: center;
|
||||
border-radius: 18px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #22c55e, #0ea5e9);
|
||||
font-size: 24px;
|
||||
box-shadow: inset 0 -24px 44px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.xx-voice-cover span {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
opacity: 0.84;
|
||||
}
|
||||
|
||||
.xx-voice-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xx-voice-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.xx-voice-title b {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--xx-slate);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-waveform {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.xx-waveform i {
|
||||
display: block;
|
||||
width: 6px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #93c5fd, #4f46e5);
|
||||
}
|
||||
|
||||
.xx-waveform i:nth-child(1) { height: 12px; }
|
||||
.xx-waveform i:nth-child(2) { height: 22px; }
|
||||
.xx-waveform i:nth-child(3) { height: 16px; }
|
||||
.xx-waveform i:nth-child(4) { height: 26px; }
|
||||
.xx-waveform i:nth-child(5) { height: 18px; }
|
||||
.xx-waveform i:nth-child(6) { height: 24px; }
|
||||
.xx-waveform i:nth-child(7) { height: 14px; }
|
||||
.xx-waveform i:nth-child(8) { height: 28px; }
|
||||
.xx-waveform i:nth-child(9) { height: 18px; }
|
||||
.xx-waveform i:nth-child(10) { height: 12px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.xx-voice-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.xx-voice-card {
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
}
|
||||
.xx-voice-cover {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
.xx-voice-card > .ant-btn {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Strict V21 mappings */
|
||||
.xx-vertical-grid.compact {
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-vertical-grid.compact .xx-vertical-card {
|
||||
border-radius: 18px;
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.xx-vertical-grid.compact .xx-vertical-thumb {
|
||||
border-radius: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.xx-vertical-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-vertical-row b {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-pill {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.01em;
|
||||
padding: 3px 7px;
|
||||
}
|
||||
|
||||
.xx-pill.ok { background: #dcfce7; color: #166534; }
|
||||
.xx-pill.warn { background: #ffedd5; color: #c2410c; }
|
||||
.xx-pill.bad { background: #ffe4e6; color: #be123c; }
|
||||
|
||||
.xx-result-video,
|
||||
.xx-result-poster {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.xx-result-video {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.xx-mini-play {
|
||||
display: grid;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.xx-sound-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 22px 30px;
|
||||
}
|
||||
|
||||
.xx-sound-card {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr 54px;
|
||||
gap: 26px;
|
||||
align-items: center;
|
||||
min-height: 116px;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.045);
|
||||
padding: 14px 18px 14px 14px;
|
||||
transition: 0.18s ease;
|
||||
}
|
||||
|
||||
.xx-sound-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: #c7d2fe;
|
||||
box-shadow: var(--xx-shadow-soft);
|
||||
}
|
||||
|
||||
.xx-sound-cover {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #111827, #334155);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-sound-cover.green { background: linear-gradient(135deg, #16a34a, #92400e); }
|
||||
.xx-sound-cover.blue { background: linear-gradient(135deg, #020617, #1d4ed8); }
|
||||
.xx-sound-cover.ice { background: linear-gradient(135deg, #0f172a, #38bdf8); }
|
||||
.xx-sound-cover.opera { background: linear-gradient(135deg, #111827, #16a34a); }
|
||||
.xx-sound-cover.food { background: linear-gradient(135deg, #991b1b, #f59e0b); }
|
||||
.xx-sound-cover.sun { background: linear-gradient(135deg, #0f172a, #f97316); }
|
||||
|
||||
.xx-sound-play {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
display: grid;
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
place-items: center;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
color: #fff;
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
|
||||
font-size: 25px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.xx-sound-title {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
margin-bottom: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-sound-wave {
|
||||
height: 28px;
|
||||
opacity: 0.65;
|
||||
background: repeating-linear-gradient(90deg, #cbd5e1 0 3px, transparent 3px 7px);
|
||||
mask: linear-gradient(90deg, transparent 0, #000 8%, #000 92%, transparent 100%);
|
||||
}
|
||||
|
||||
.xx-sound-time {
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.xx-sound-progress {
|
||||
width: 100px;
|
||||
height: 10px;
|
||||
margin: -10px 18px 8px auto;
|
||||
border-radius: 999px;
|
||||
background: #34d399;
|
||||
box-shadow: 0 6px 16px rgba(52, 211, 153, 0.25);
|
||||
}
|
||||
|
||||
.xx-audio-inline {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.xx-vertical-grid.compact { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.xx-vertical-grid.compact { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.xx-sound-grid { grid-template-columns: 1fr; }
|
||||
.xx-sound-card { grid-template-columns: 82px 1fr 46px; gap: 14px; }
|
||||
.xx-sound-cover { width: 82px; height: 82px; }
|
||||
.xx-sound-title { font-size: 17px; margin-bottom: 18px; }
|
||||
.xx-sound-time { font-size: 14px; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.xx-vertical-grid.compact { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.xx-vertical-grid.compact .xx-vertical-card { padding: 8px; }
|
||||
.xx-vertical-grid.compact b { font-size: 12px; }
|
||||
.xx-sound-card { grid-template-columns: 70px 1fr; position: relative; padding-right: 14px; }
|
||||
.xx-sound-cover { width: 70px; height: 70px; }
|
||||
.xx-sound-play { width: 46px; height: 46px; }
|
||||
.xx-sound-time { position: absolute; right: 14px; bottom: 14px; }
|
||||
.xx-sound-wave { max-width: 150px; }
|
||||
}
|
||||
|
||||
.xx-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.xx-hint {
|
||||
border: 1px solid var(--xx-line);
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: var(--xx-muted);
|
||||
font-size: 13px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.xx-diagnosis-strip {
|
||||
display: grid;
|
||||
grid-template-columns: 150px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
border: 1px solid #edf2f7;
|
||||
border-radius: 22px;
|
||||
background: #fff;
|
||||
margin-bottom: 18px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.xx-title-row .xx-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.xx-filter-row .ant-select,
|
||||
.xx-filter-row .ant-btn,
|
||||
.xx-filter-row .ant-input-search {
|
||||
width: 100% !important;
|
||||
}
|
||||
.xx-diagnosis-strip { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.xx-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
.xx-pricing-head {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-pricing-head h1 {
|
||||
margin-top: 16px;
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.xx-pricing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.xx-pricing-card {
|
||||
position: relative;
|
||||
border: 1px solid var(--xx-line);
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
padding: 22px;
|
||||
box-shadow: var(--xx-shadow-soft);
|
||||
}
|
||||
|
||||
.xx-pricing-card.recommended {
|
||||
border: 2px solid var(--xx-primary);
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
.xx-pricing-card h2 {
|
||||
margin: 18px 0 8px;
|
||||
color: var(--xx-slate);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.xx-pricing-card p {
|
||||
min-height: 48px;
|
||||
color: var(--xx-muted);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.xx-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.xx-price b {
|
||||
font-size: 36px;
|
||||
color: var(--xx-slate);
|
||||
}
|
||||
|
||||
.xx-price span {
|
||||
color: var(--xx-muted);
|
||||
}
|
||||
|
||||
.xx-feature-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-height: 180px;
|
||||
margin: 18px 0 22px;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.xx-pricing-grid { grid-template-columns: 1fr; }
|
||||
.xx-pricing-card.recommended { transform: none; }
|
||||
.xx-pricing-head h1 { font-size: 28px; }
|
||||
}
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
/**
|
||||
* 主布局组件
|
||||
* 包含 Header、Sidebar、Content 区域
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Layout } from 'antd';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
import Sidebar from './Sidebar';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import './MainLayout.css';
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
const sidebarCollapsed = useUIStore((state) => state.sidebarCollapsed);
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
<Layout>
|
||||
<Header />
|
||||
<Content style={{ margin: '24px 16px', padding: 24, background: '#fff' }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
<Layout className="xx-app-shell">
|
||||
<Header />
|
||||
<Content className="xx-app-content">
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,9 +10,6 @@ import {
|
||||
CrownOutlined,
|
||||
UserOutlined,
|
||||
DashboardOutlined,
|
||||
BarChartOutlined,
|
||||
MonitorOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import type { MenuProps } from 'antd';
|
||||
@@ -38,13 +35,13 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
key: '/projects',
|
||||
icon: <AppstoreOutlined />,
|
||||
label: '项目',
|
||||
onClick: () => navigate('/projects'),
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: '/members',
|
||||
icon: <TeamOutlined />,
|
||||
label: '成员管理',
|
||||
onClick: () => navigate('/members'),
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: '/subscription',
|
||||
@@ -55,39 +52,8 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
{
|
||||
key: '/admin',
|
||||
icon: <DashboardOutlined />,
|
||||
label: 'Admin',
|
||||
children: [
|
||||
{
|
||||
key: '/admin',
|
||||
icon: <DashboardOutlined />,
|
||||
label: 'Dashboard',
|
||||
onClick: () => navigate('/admin'),
|
||||
},
|
||||
{
|
||||
key: '/admin/users',
|
||||
icon: <TeamOutlined />,
|
||||
label: '用户管理',
|
||||
onClick: () => navigate('/admin/users'),
|
||||
},
|
||||
{
|
||||
key: '/admin/analytics',
|
||||
icon: <BarChartOutlined />,
|
||||
label: '数据分析',
|
||||
onClick: () => navigate('/admin/analytics'),
|
||||
},
|
||||
{
|
||||
key: '/admin/monitor',
|
||||
icon: <MonitorOutlined />,
|
||||
label: '系统监控',
|
||||
onClick: () => navigate('/admin/monitor'),
|
||||
},
|
||||
{
|
||||
key: '/admin/logs',
|
||||
icon: <FileTextOutlined />,
|
||||
label: '日志查看',
|
||||
onClick: () => navigate('/admin/logs'),
|
||||
},
|
||||
],
|
||||
label: 'Admin(暂未开放)',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: '/profile',
|
||||
|
||||
@@ -11,34 +11,50 @@ export const useLogin = () => {
|
||||
const navigate = useNavigate();
|
||||
const setAuth = useAuthStore((state) => state.setAuth);
|
||||
|
||||
return useMutation({
|
||||
const mutation = useMutation({
|
||||
mutationFn: authApi.login,
|
||||
onSuccess: (data) => {
|
||||
// 先保存 token
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
|
||||
// 获取用户信息
|
||||
authApi.getCurrentUser().then((user) => {
|
||||
setAuth(user, data.access_token, data.refresh_token);
|
||||
navigate('/workspaces');
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// 手动处理成功后的逻辑
|
||||
const login = async (credentials: Parameters<typeof authApi.login>[0]) => {
|
||||
const data = await mutation.mutateAsync(credentials);
|
||||
const refreshToken = data.refresh_token ?? null;
|
||||
|
||||
// 先保存 token
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
} else {
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const user = await authApi.getCurrentUser();
|
||||
setAuth(user, data.access_token, refreshToken);
|
||||
navigate('/workspaces');
|
||||
return data;
|
||||
};
|
||||
|
||||
return { ...mutation, mutateAsync: login };
|
||||
};
|
||||
|
||||
// 注册 Hook
|
||||
export const useRegister = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return useMutation({
|
||||
const mutation = useMutation({
|
||||
mutationFn: authApi.register,
|
||||
onSuccess: () => {
|
||||
navigate('/login', {
|
||||
state: { message: '注册成功!请查收验证邮件。' },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const register = async (data: Parameters<typeof authApi.register>[0]) => {
|
||||
const result = await mutation.mutateAsync(data);
|
||||
navigate('/login', {
|
||||
state: { message: '注册成功!请查收验证邮件。' },
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
return { ...mutation, mutateAsync: register };
|
||||
};
|
||||
|
||||
// 登出 Hook
|
||||
@@ -47,33 +63,32 @@ export const useLogout = () => {
|
||||
const clearAuth = useAuthStore((state) => state.clearAuth);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
const mutation = useMutation({
|
||||
mutationFn: authApi.logout,
|
||||
onSuccess: () => {
|
||||
clearAuth();
|
||||
queryClient.clear();
|
||||
navigate('/login');
|
||||
},
|
||||
onError: () => {
|
||||
// 即使登出失败也清除本地状态
|
||||
clearAuth();
|
||||
queryClient.clear();
|
||||
navigate('/login');
|
||||
},
|
||||
});
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await mutation.mutateAsync();
|
||||
} catch (error) {
|
||||
// 即使登出失败也清除本地状态
|
||||
} finally {
|
||||
clearAuth();
|
||||
queryClient.clear();
|
||||
navigate('/login');
|
||||
}
|
||||
};
|
||||
|
||||
return { ...mutation, mutateAsync: logout };
|
||||
};
|
||||
|
||||
// 获取当前用户 Hook
|
||||
export const useCurrentUser = () => {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const setUser = useAuthStore((state) => state.setUser);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['currentUser'],
|
||||
queryFn: authApi.getCurrentUser,
|
||||
enabled: isAuthenticated,
|
||||
onSuccess: (user) => {
|
||||
setUser(user);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,18 +3,12 @@
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import * as workspaceApi from '@/api/workspace';
|
||||
import { useWorkspaceStore } from '@/store/workspaceStore';
|
||||
|
||||
// 获取工作空间列表
|
||||
export const useWorkspaces = () => {
|
||||
const setWorkspaces = useWorkspaceStore((state) => state.setWorkspaces);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['workspaces'],
|
||||
queryFn: workspaceApi.getWorkspaces,
|
||||
onSuccess: (data) => {
|
||||
setWorkspaces(data);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -30,15 +24,18 @@ export const useWorkspace = (id: string) => {
|
||||
// 创建工作空间
|
||||
export const useCreateWorkspace = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const addWorkspace = useWorkspaceStore((state) => state.addWorkspace);
|
||||
|
||||
return useMutation({
|
||||
const mutation = useMutation({
|
||||
mutationFn: workspaceApi.createWorkspace,
|
||||
onSuccess: (data) => {
|
||||
addWorkspace(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['workspaces'] });
|
||||
},
|
||||
});
|
||||
|
||||
const createWorkspace = async (data: Parameters<typeof workspaceApi.createWorkspace>[0]) => {
|
||||
const result = await mutation.mutateAsync(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['workspaces'] });
|
||||
return result;
|
||||
};
|
||||
|
||||
return { ...mutation, mutateAsync: createWorkspace };
|
||||
};
|
||||
|
||||
// 获取成员列表
|
||||
@@ -54,11 +51,16 @@ export const useWorkspaceMembers = (workspaceId: string) => {
|
||||
export const useInviteMember = (workspaceId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: workspaceApi.InviteMemberRequest) =>
|
||||
workspaceApi.inviteMember(workspaceId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workspaceMembers', workspaceId] });
|
||||
},
|
||||
});
|
||||
|
||||
const inviteMember = async (data: workspaceApi.InviteMemberRequest) => {
|
||||
const result = await mutation.mutateAsync(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['workspaceMembers', workspaceId] });
|
||||
return result;
|
||||
};
|
||||
|
||||
return { ...mutation, mutateAsync: inviteMember };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Button, Card, Result } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const AdminComingSoon: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<Card>
|
||||
<Result
|
||||
status="info"
|
||||
title="Admin 后台暂未开放"
|
||||
subTitle="当前版本未接入后台用户、监控、日志、分析等后端服务,因此不展示模拟运营数据,也不会提供假操作入口。"
|
||||
extra={[
|
||||
<Button key="workspaces" type="primary" onClick={() => navigate('/workspaces')}>
|
||||
返回工作空间
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminComingSoon;
|
||||
|
||||
export const Component = AdminComingSoon;
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import {
|
||||
TrendingUpOutlined,
|
||||
ArrowUpOutlined,
|
||||
UserAddOutlined,
|
||||
DollarOutlined,
|
||||
ProjectOutlined,
|
||||
@@ -58,7 +58,7 @@ const Analytics: React.FC = () => {
|
||||
// 活跃度统计
|
||||
const activityStats = [
|
||||
{ metric: '日活跃用户 (DAU)', value: 892, growth: '+12.5%', icon: <UserAddOutlined /> },
|
||||
{ metric: '月活跃用户 (MAU)', value: 3456, growth: '+8.3%', icon: <TrendingUpOutlined /> },
|
||||
{ metric: '月活跃用户 (MAU)', value: 3456, growth: '+8.3%', icon: <ArrowUpOutlined /> },
|
||||
{ metric: '本月收入', value: 13365, prefix: '¥', growth: '+51.6%', icon: <DollarOutlined /> },
|
||||
{ metric: '活跃项目数', value: 2341, growth: '+18.2%', icon: <ProjectOutlined /> },
|
||||
];
|
||||
|
||||
@@ -79,7 +79,7 @@ const LogViewer: React.FC = () => {
|
||||
id: '5',
|
||||
timestamp: '2024-06-17 17:08:52',
|
||||
level: 'WARN',
|
||||
service: 'MinIO',
|
||||
service: 'ObjectStorage',
|
||||
action: 'Slow Upload',
|
||||
details: 'File upload took 3.2s (>1s threshold)',
|
||||
ip: '192.168.1.102',
|
||||
@@ -164,7 +164,7 @@ const LogViewer: React.FC = () => {
|
||||
{ text: 'API', value: 'API' },
|
||||
{ text: 'Database', value: 'Database' },
|
||||
{ text: 'Celery', value: 'Celery' },
|
||||
{ text: 'MinIO', value: 'MinIO' },
|
||||
{ text: 'ObjectStorage', value: 'ObjectStorage' },
|
||||
{ text: 'Redis', value: 'Redis' },
|
||||
],
|
||||
onFilter: (value, record) => record.service === value,
|
||||
@@ -240,14 +240,14 @@ const LogViewer: React.FC = () => {
|
||||
<Option value="API">API</Option>
|
||||
<Option value="Database">Database</Option>
|
||||
<Option value="Celery">Celery</Option>
|
||||
<Option value="MinIO">MinIO</Option>
|
||||
<Option value="ObjectStorage">Object Storage</Option>
|
||||
<Option value="Redis">Redis</Option>
|
||||
</Select>
|
||||
|
||||
<RangePicker showTime />
|
||||
|
||||
<Button icon={<FilterOutlined />}>应用过滤</Button>
|
||||
<Button icon={<DownloadOutlined />}>导出日志</Button>
|
||||
<Button icon={<FilterOutlined />} disabled>应用过滤暂未开放</Button>
|
||||
<Button icon={<DownloadOutlined />} disabled>导出日志暂未开放</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ const SystemMonitor: React.FC = () => {
|
||||
{ name: 'PostgreSQL', status: 'healthy', uptime: '99.99%', responseTime: '8ms' },
|
||||
{ name: 'Redis', status: 'healthy', uptime: '99.97%', responseTime: '2ms' },
|
||||
{ name: 'Celery Worker', status: 'healthy', uptime: '99.95%', responseTime: '-' },
|
||||
{ name: 'MinIO', status: 'warning', uptime: '99.85%', responseTime: '120ms' },
|
||||
{ name: 'Object Storage', status: 'warning', uptime: '99.85%', responseTime: '120ms' },
|
||||
];
|
||||
|
||||
// API 请求统计
|
||||
@@ -46,7 +46,7 @@ const SystemMonitor: React.FC = () => {
|
||||
// 错误日志
|
||||
const recentErrors = [
|
||||
{ time: '2024-06-17 16:45:32', level: 'ERROR', service: 'API', message: 'Database connection timeout' },
|
||||
{ time: '2024-06-17 16:42:15', level: 'WARN', service: 'MinIO', message: 'Slow response detected (>1s)' },
|
||||
{ time: '2024-06-17 16:42:15', level: 'WARN', service: 'Object Storage', message: 'Slow response detected (>1s)' },
|
||||
{ time: '2024-06-17 16:38:41', level: 'ERROR', service: 'Celery', message: 'Task retry limit exceeded' },
|
||||
];
|
||||
|
||||
@@ -67,8 +67,8 @@ const SystemMonitor: React.FC = () => {
|
||||
warning: { color: 'warning', icon: <SyncOutlined spin />, text: '警告' },
|
||||
error: { color: 'error', icon: <CloseCircleOutlined />, text: '故障' },
|
||||
};
|
||||
const { color, icon, text } = config[status] || config.healthy;
|
||||
return <Badge status={color as any} icon={icon} text={text} />;
|
||||
const { color, text } = config[status] || config.healthy;
|
||||
return <Badge status={color as any} text={text} />;
|
||||
},
|
||||
},
|
||||
{ title: '可用性', dataIndex: 'uptime', key: 'uptime' },
|
||||
@@ -104,17 +104,19 @@ const SystemMonitor: React.FC = () => {
|
||||
{ title: '错误信息', dataIndex: 'message', key: 'message' },
|
||||
];
|
||||
|
||||
const refreshMetrics = () => {
|
||||
setSystemMetrics({
|
||||
cpu: Math.floor(Math.random() * 30) + 30,
|
||||
memory: Math.floor(Math.random() * 20) + 55,
|
||||
disk: Math.floor(Math.random() * 10) + 45,
|
||||
network: Math.floor(Math.random() * 40) + 20,
|
||||
});
|
||||
setRefreshTime(new Date());
|
||||
};
|
||||
|
||||
// 模拟实时更新
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setSystemMetrics({
|
||||
cpu: Math.floor(Math.random() * 30) + 30,
|
||||
memory: Math.floor(Math.random() * 20) + 55,
|
||||
disk: Math.floor(Math.random() * 10) + 45,
|
||||
network: Math.floor(Math.random() * 40) + 20,
|
||||
});
|
||||
setRefreshTime(new Date());
|
||||
}, 5000);
|
||||
const interval = setInterval(refreshMetrics, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
@@ -131,7 +133,7 @@ const SystemMonitor: React.FC = () => {
|
||||
<h1>系统监控</h1>
|
||||
<Space>
|
||||
<span style={{ color: '#8c8c8c' }}>上次更新: {refreshTime.toLocaleTimeString()}</span>
|
||||
<Button icon={<ReloadOutlined />}>刷新</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={refreshMetrics}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -69,16 +69,17 @@ const UserManagement: React.FC = () => {
|
||||
key: 'action',
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button type="link" size="small">
|
||||
详情
|
||||
<Button type="link" size="small" disabled>
|
||||
详情暂未开放
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger={record.status === 'active'}
|
||||
icon={record.status === 'active' ? <LockOutlined /> : <UnlockOutlined />}
|
||||
disabled
|
||||
>
|
||||
{record.status === 'active' ? '封禁' : '解封'}
|
||||
{record.status === 'active' ? '封禁暂未开放' : '解封暂未开放'}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
|
||||
@@ -25,6 +25,7 @@ const Register: React.FC = () => {
|
||||
email: values.email,
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
display_name: values.username,
|
||||
});
|
||||
message.success('注册成功!请查收验证邮件。');
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -2,30 +2,30 @@
|
||||
* 账号安全设置页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Form, Input, Button, message, Divider } from 'antd';
|
||||
import { Card, Form, Input, Button, Alert } from 'antd';
|
||||
import { LockOutlined, MailOutlined } from '@ant-design/icons';
|
||||
|
||||
const AccountSecurity: React.FC = () => {
|
||||
const [passwordForm] = Form.useForm();
|
||||
const [emailForm] = Form.useForm();
|
||||
|
||||
const onPasswordSubmit = (values: any) => {
|
||||
console.log('修改密码:', values);
|
||||
message.success('密码修改成功');
|
||||
passwordForm.resetFields();
|
||||
};
|
||||
const onPasswordSubmit = () => undefined;
|
||||
|
||||
const onEmailSubmit = () => undefined;
|
||||
|
||||
const onEmailSubmit = (values: any) => {
|
||||
console.log('修改邮箱:', values);
|
||||
message.success('验证邮件已发送到新邮箱');
|
||||
emailForm.resetFields();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h1>账号安全</h1>
|
||||
|
||||
<Card title="修改密码" style={{ marginBottom: 24 }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="账号安全设置暂未开放"
|
||||
description="当前后端尚未提供登录态修改密码/邮箱接口,避免假成功,入口暂时禁用。"
|
||||
/>
|
||||
<Form form={passwordForm} layout="vertical" onFinish={onPasswordSubmit}>
|
||||
<Form.Item
|
||||
name="currentPassword"
|
||||
@@ -66,8 +66,8 @@ const AccountSecurity: React.FC = () => {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
修改密码
|
||||
<Button type="primary" htmlType="submit" disabled>
|
||||
修改密码暂未开放
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -95,8 +95,8 @@ const AccountSecurity: React.FC = () => {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
修改邮箱
|
||||
<Button type="primary" htmlType="submit" disabled>
|
||||
修改邮箱暂未开放
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -2,20 +2,26 @@
|
||||
* 通知设置页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Form, Switch, Button, message, Divider } from 'antd';
|
||||
import { Card, Form, Switch, Button, Divider, Alert } from 'antd';
|
||||
|
||||
|
||||
const NotificationSettings: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const onFinish = (values: any) => {
|
||||
console.log('保存通知设置:', values);
|
||||
message.success('通知设置已保存');
|
||||
};
|
||||
const onFinish = () => undefined;
|
||||
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h1>通知设置</h1>
|
||||
<Card>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="通知偏好暂未开放"
|
||||
description="当前后端尚未提供通知偏好保存接口,避免假成功,保存入口暂时禁用。"
|
||||
/>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
@@ -75,8 +81,8 @@ const NotificationSettings: React.FC = () => {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存设置
|
||||
<Button type="primary" htmlType="submit" disabled>
|
||||
保存设置暂未开放
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -1,116 +1,15 @@
|
||||
/**
|
||||
* Session 管理页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Table, Button, Tag, Space, Popconfirm, message } from 'antd';
|
||||
import { LaptopOutlined, MobileOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
device: string;
|
||||
deviceType: 'desktop' | 'mobile';
|
||||
location: string;
|
||||
lastActive: string;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
import { Card, Result } from 'antd';
|
||||
|
||||
const SessionManagement: React.FC = () => {
|
||||
// 模拟数据
|
||||
const sessions: Session[] = [
|
||||
{
|
||||
id: '1',
|
||||
device: 'Chrome on Windows',
|
||||
deviceType: 'desktop',
|
||||
location: '北京, 中国',
|
||||
lastActive: '当前会话',
|
||||
isCurrent: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
device: 'Safari on iPhone',
|
||||
deviceType: 'mobile',
|
||||
location: '上海, 中国',
|
||||
lastActive: '2 小时前',
|
||||
isCurrent: false,
|
||||
},
|
||||
];
|
||||
|
||||
const handleLogout = (sessionId: string) => {
|
||||
console.log('登出会话:', sessionId);
|
||||
message.success('会话已结束');
|
||||
};
|
||||
|
||||
const handleLogoutAll = () => {
|
||||
console.log('登出所有其他设备');
|
||||
message.success('所有其他设备已登出');
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '设备',
|
||||
dataIndex: 'device',
|
||||
key: 'device',
|
||||
render: (device: string, record: Session) => (
|
||||
<Space>
|
||||
{record.deviceType === 'desktop' ? <LaptopOutlined /> : <MobileOutlined />}
|
||||
<span>{device}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '位置',
|
||||
dataIndex: 'location',
|
||||
key: 'location',
|
||||
},
|
||||
{
|
||||
title: '最后活动',
|
||||
dataIndex: 'lastActive',
|
||||
key: 'lastActive',
|
||||
render: (text: string, record: Session) => (
|
||||
<>
|
||||
{text}
|
||||
{record.isCurrent && <Tag color="success" style={{ marginLeft: 8 }}>当前</Tag>}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: Session) => {
|
||||
if (record.isCurrent) {
|
||||
return <span style={{ color: '#999' }}>-</span>;
|
||||
}
|
||||
return (
|
||||
<Popconfirm
|
||||
title="确定要结束此会话吗?"
|
||||
onConfirm={() => handleLogout(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>
|
||||
结束会话
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h1>Session 管理</h1>
|
||||
<Card>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Popconfirm
|
||||
title="确定要登出所有其他设备吗?"
|
||||
onConfirm={handleLogoutAll}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button danger>登出所有其他设备</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<Table columns={columns} dataSource={sessions} rowKey="id" pagination={false} />
|
||||
<div>
|
||||
<Card title="会话管理">
|
||||
<Result
|
||||
status="info"
|
||||
title="会话管理暂未开放"
|
||||
subTitle="当前版本未接入会话列表、设备管理和远程登出后端服务,因此不展示模拟设备,也不会伪造登出操作。"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user