Compare commits

..

1 Commits

Author SHA1 Message Date
CI Bot e11366cae1 test(preview): 预览环境端到端验证测试PR 2026-07-18 11:50:19 +08:00
487 changed files with 159 additions and 66306 deletions
-68
View File
@@ -1,68 +0,0 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
ENV/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Environment
.env
.env.local
.env.*.local
# Database
*.db
*.sqlite3
# Logs
logs/
*.log
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
# Docker
.dockerignore
# OS
.DS_Store
Thumbs.db
# Temporary
tmp/
temp/
*.tmp
# Backup
*.bak
*.backup
-44
View File
@@ -1,44 +0,0 @@
# 小虾 SaaS 环境变量配置
# ==================== 应用配置 ====================
APP_NAME=小虾 SaaS
BASE_URL=http://localhost:3000
# ==================== 数据库配置 ====================
DATABASE_URL=postgresql://xiaoxia_user:your_password@localhost:5432/xiaoxia_saas
# 开发环境:使用内存数据库(不需要 PostgreSQL)
USE_IN_MEMORY_DB=true
# 生产环境:使用 PostgreSQL
# USE_IN_MEMORY_DB=false
# ==================== Redis 配置 ====================
REDIS_URL=redis://localhost:6379/0
# ==================== JWT 配置 ====================
JWT_SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-chars
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
# ==================== 邮件配置 ====================
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-specific-password
SMTP_FROM_EMAIL=noreply@xiaoxia-saas.com
SMTP_FROM_NAME=小虾 SaaS
# ==================== 环境配置 ====================
ENVIRONMENT=development
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
-85
View File
@@ -1,85 +0,0 @@
# =======================
# 生产环境配置
# =======================
APP_ENV=production
APP_NAME=xiaoxia-saas
APP_VERSION=0.1.0
DEBUG=false
# =======================
# API 服务配置
# =======================
API_HOST=0.0.0.0
API_PORT=8000
API_PREFIX=/api/v1
# =======================
# Web 前端配置
# =======================
WEB_PORT=3000
WEB_URL=https://xiaoxiajianji.com
# =======================
# 数据库配置
# =======================
DATABASE_URL=postgresql+psycopg://postgres:CHANGE_ME@postgres:5432/xiaoxia_saas_production
DATABASE_POOL_SIZE=50
DATABASE_MAX_OVERFLOW=100
DATABASE_POOL_TIMEOUT=30
DATABASE_POOL_RECYCLE=3600
# =======================
# Redis 配置
# =======================
REDIS_URL=redis://redis:6379/0
REDIS_MAX_CONNECTIONS=100
# =======================
# Celery Worker 配置
# =======================
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/1
CELERY_WORKER_CONCURRENCY=8
CELERY_WORKER_MAX_TASKS_PER_CHILD=1000
# =======================
# MinIO 对象存储配置
# =======================
MINIO_ENDPOINT=47.98.113.167:9000
MINIO_ACCESS_KEY=CHANGE_ME_PRODUCTION
MINIO_SECRET_KEY=CHANGE_ME_PRODUCTION
MINIO_BUCKET=xiaoxia-assets
MINIO_SECURE=false
MINIO_PUBLIC_URL=http://47.98.113.167:9000
# =======================
# 日志配置
# =======================
LOG_LEVEL=INFO
LOG_FORMAT=json
LOG_FILE=/var/log/xiaoxia-saas/app.log
# =======================
# CORS 配置
# =======================
CORS_ORIGINS=https://xiaoxiajianji.com,https://api.xiaoxiajianji.com
CORS_ALLOW_CREDENTIALS=true
# =======================
# 文件上传限制
# =======================
MAX_UPLOAD_SIZE_MB=2000
ALLOWED_FILE_TYPES=video/mp4,video/quicktime,video/x-msvideo,audio/mpeg,audio/wav,image/jpeg,image/png,image/gif
# =======================
# 安全配置
# =======================
SECRET_KEY=CHANGE_ME_TO_RANDOM_STRING_AT_LEAST_32_CHARS_IN_PRODUCTION
ACCESS_TOKEN_EXPIRE_MINUTES=60
REFRESH_TOKEN_EXPIRE_DAYS=7
# =======================
# 监控与追踪(可选)
# =======================
# SENTRY_DSN=
# PROMETHEUS_PORT=9090
-30
View File
@@ -1,30 +0,0 @@
# 生产环境配置模板(实际使用时复制为 .env.production)
ENVIRONMENT=production
DEBUG=false
USE_IN_MEMORY_DB=false
LOG_LEVEL=WARNING
# 数据库(必须修改)
DATABASE_URL=postgresql://prod_user:CHANGE_THIS_PASSWORD@db-prod:5432/xiaoxia_prod
# Redis(必须修改)
REDIS_URL=redis://:CHANGE_THIS_PASSWORD@redis-prod:6379/0
# JWT(必须修改,至少 32 字符)
JWT_SECRET_KEY=CHANGE_THIS_TO_A_RANDOM_SECRET_KEY_AT_LEAST_32_CHARS
# SMTP(必须配置)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-specific-password
SMTP_FROM_EMAIL=noreply@yourdomain.com
# 应用配置
BASE_URL=https://yourdomain.com
# CORS(修改为实际域名)
CORS_ORIGINS=["https://yourdomain.com","https://app.yourdomain.com"]
# 监控(可选)
SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id
-85
View File
@@ -1,85 +0,0 @@
# =======================
# Staging 环境配置
# =======================
APP_ENV=staging
APP_NAME=xiaoxia-saas
APP_VERSION=0.1.0
DEBUG=true
# =======================
# API 服务配置
# =======================
API_HOST=0.0.0.0
API_PORT=8000
API_PREFIX=/api/v1
# =======================
# Web 前端配置
# =======================
WEB_PORT=3000
WEB_URL=http://47.98.113.167:3001
# =======================
# 数据库配置
# =======================
DATABASE_URL=postgresql+psycopg://postgres:CHANGE_ME_STAGING_DB_PASSWORD@postgres:5432/xiaoxia_saas_staging
DATABASE_POOL_SIZE=20
DATABASE_MAX_OVERFLOW=40
DATABASE_POOL_TIMEOUT=30
DATABASE_POOL_RECYCLE=3600
# =======================
# Redis 配置
# =======================
REDIS_URL=redis://redis:6379/1
REDIS_MAX_CONNECTIONS=50
# =======================
# Celery Worker 配置
# =======================
CELERY_BROKER_URL=redis://redis:6379/1
CELERY_RESULT_BACKEND=redis://redis:6379/2
CELERY_WORKER_CONCURRENCY=4
CELERY_WORKER_MAX_TASKS_PER_CHILD=1000
# =======================
# MinIO 对象存储配置
# =======================
MINIO_ENDPOINT=47.98.113.167:9000
MINIO_ACCESS_KEY=CHANGE_ME_STAGING
MINIO_SECRET_KEY=CHANGE_ME_STAGING
MINIO_BUCKET=xiaoxia-assets
MINIO_SECURE=false
MINIO_PUBLIC_URL=http://47.98.113.167:9000
# =======================
# 日志配置
# =======================
LOG_LEVEL=DEBUG
LOG_FORMAT=json
LOG_FILE=/var/log/xiaoxia-saas/staging.log
# =======================
# CORS 配置
# =======================
CORS_ORIGINS=http://47.98.113.167:3001,http://47.98.113.167:8001
CORS_ALLOW_CREDENTIALS=true
# =======================
# 文件上传限制
# =======================
MAX_UPLOAD_SIZE_MB=1000
ALLOWED_FILE_TYPES=video/mp4,video/quicktime,video/x-msvideo,audio/mpeg,audio/wav,image/jpeg,image/png,image/gif
# =======================
# 安全配置
# =======================
SECRET_KEY=CHANGE_ME_STAGING_SECRET_KEY
ACCESS_TOKEN_EXPIRE_MINUTES=60
REFRESH_TOKEN_EXPIRE_DAYS=7
# =======================
# 监控与追踪(可选)
# =======================
# SENTRY_DSN=
# PROMETHEUS_PORT=9090
-3
View File
@@ -1,3 +0,0 @@
*.sh text eol=lf
infra/docker/*.sh text eol=lf
scripts/*.sh text eol=lf
-96
View File
@@ -1,96 +0,0 @@
name: CI/CD Pipeline
on:
push:
branches:
- main
- develop
- 'feature/**'
- 'bugfix/**'
- 'hotfix/**'
- 'release/**'
pull_request:
branches:
- main
- develop
jobs:
validate:
name: Validate Code Quality And Tests
runs-on: ubuntu-latest
container: xiaoxia-ci-python:3.12
steps:
- name: Checkout code
run: |
python - <<'PY'
import os
import tarfile
import urllib.request
api_url = os.environ['GITHUB_API_URL']
repository = os.environ['GITHUB_REPOSITORY']
sha = os.environ['GITHUB_SHA']
token = os.environ.get('GITHUB_TOKEN', '')
archive_url = f"{api_url}/repos/{repository}/archive/{sha}.tar.gz"
request = urllib.request.Request(archive_url)
if token:
request.add_header('Authorization', f'token {token}')
with urllib.request.urlopen(request, timeout=120) as response:
with open('/tmp/repo.tar.gz', 'wb') as archive:
archive.write(response.read())
with tarfile.open('/tmp/repo.tar.gz', 'r:gz') as archive:
members = archive.getmembers()
top_level = members[0].name.split('/')[0] + '/'
for member in members:
member.name = member.name.removeprefix(top_level)
if member.name:
archive.extract(member, '.')
PY
- name: Verify CI environment
run: |
python --version
python -m pip --version
python -m black --version
python -m isort --version-number
python -m flake8 --version
bandit --version
pytest --version
echo "✅ Prebuilt CI environment is ready"
- name: Run code quality checks
run: |
python -m compileall -q alembic apps packages tests scripts
python -m black --check alembic apps packages tests scripts
python -m isort --check-only alembic apps packages tests scripts
python -m flake8 apps packages tests --count --statistics
- name: Run security scan
run: |
bandit -r apps packages -q
- name: Validate release scripts syntax
run: |
bash -n scripts/backup_postgres.sh
bash -n scripts/restore_postgres_plan.sh
bash -n scripts/init_production_env.sh
- name: Validate Alembic migrations
run: |
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas \
python -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql
test -s /tmp/alembic-upgrade.sql
grep -q "Running upgrade" /tmp/alembic-upgrade.sql
python scripts/check_schema_metadata.py
- name: Run tests
run: |
python -m pytest tests -q
- name: Build summary
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
run: |
echo "✅ Build completed successfully!"
echo "Branch: ${GITHUB_REF_NAME}"
echo "Commit: ${GITHUB_SHA}"
-246
View File
@@ -1,246 +0,0 @@
name: Deploy
on:
push:
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' || github.ref == 'refs/heads/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"
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: 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
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
'
- name: Verify staging health
shell: sh
run: |
set -eu
i=0
while [ "$i" -lt 30 ]; do
if wget -qO- http://127.0.0.1:8000/health; then
exit 0
fi
i=$((i + 1))
sleep 2
done
exit 1
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"
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: Build runtime image artifact
shell: sh
run: |
set -eu
chmod +x scripts/build_release_images.sh
scripts/build_release_images.sh "${GITHUB_REF_NAME}"
- name: Build web artifact
shell: sh
run: |
set -eu
docker run --rm \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc 'npm ci && npm run build'
- 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" .
- 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"
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"
mkdir -p /var/lib/xiaoxia-saas-production
old_assets_dir="/tmp/xiaoxia-previous-web-assets-${RELEASE_VERSION}"
rm -rf "$old_assets_dir"
if [ -d /var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets ]; then
mkdir -p "$old_assets_dir"
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= 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: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 --project=chromium e2e/core-upload.spec.ts e2e/core-generation.spec.ts'
-119
View File
@@ -1,119 +0,0 @@
name: Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
container:
image: python:3.12-slim
steps:
- name: Checkout code
shell: sh
run: |
set -eu
python - <<'PY'
import io
import os
import tarfile
import urllib.request
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Show Python version
shell: sh
run: |
set -eu
python --version
python -m pip --version
- name: Install dependencies
shell: sh
run: |
set -eu
python -m pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
python -m pip install -r requirements.txt -r requirements-dev.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
- name: Run tests
shell: sh
run: |
set -eu
python -m pytest tests/integration/ -v --cov=packages --cov=apps --cov-report=xml --cov-report=term
lint:
runs-on: ubuntu-latest
container:
image: python:3.12-slim
steps:
- name: Checkout code
shell: sh
run: |
set -eu
python - <<'PY'
import io
import os
import tarfile
import urllib.request
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Install dependencies
shell: sh
run: |
set -eu
python -m pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
python -m pip install -r requirements.txt -r requirements-dev.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
- name: Run Black (check only)
shell: sh
run: |
set -eu
python -m black --check packages/ apps/ tests/
- 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
-75
View File
@@ -1,75 +0,0 @@
name: Bug Report
description: Report a bug or issue
title: "[Bug]: "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
感谢报告 Bug!请提供以下信息帮助我们诊断和修复问题。
- type: textarea
id: description
attributes:
label: Bug 描述
description: 清晰简洁地描述这个 bug
placeholder: 当我尝试... 时,发生了...
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: 复现步骤
description: 如何复现这个问题
placeholder: |
1. 进入 '...'
2. 点击 '...'
3. 滚动到 '...'
4. 看到错误
validations:
required: true
- type: textarea
id: expected
attributes:
label: 期望行为
description: 你期望发生什么?
placeholder: 应该显示...
validations:
required: true
- type: textarea
id: actual
attributes:
label: 实际行为
description: 实际发生了什么?
placeholder: 却显示了...
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息
description: 请提供环境相关信息
value: |
- OS: [e.g. Ubuntu 22.04]
- Python: [e.g. 3.12]
- FastAPI: [e.g. 0.115.0]
- 浏览器: [e.g. Chrome 120]
validations:
required: true
- type: textarea
id: logs
attributes:
label: 相关日志
description: 如果有的话,请粘贴相关的错误日志
render: shell
- type: textarea
id: additional
attributes:
label: 额外信息
description: 其他任何相关信息
@@ -1,40 +0,0 @@
name: Feature Request
description: Suggest a new feature or improvement
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
感谢你的功能建议!请详细描述你的想法。
- type: textarea
id: problem
attributes:
label: 问题描述
description: 这个功能解决什么问题?
placeholder: 当我想要... 时,目前无法...
validations:
required: true
- type: textarea
id: solution
attributes:
label: 建议方案
description: 你期望的解决方案是什么?
placeholder: 我希望能够...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 替代方案
description: 你考虑过哪些替代方案?
placeholder: 我也考虑过...
- type: textarea
id: additional
attributes:
label: 额外信息
description: 其他任何相关信息、截图、参考等
-36
View File
@@ -1,36 +0,0 @@
## Pull Request
### 变更类型
- [ ] 新功能
- [ ] Bug 修复
- [ ] 文档更新
- [ ] 重构
- [ ] 性能优化
- [ ] 测试
- [ ] 其他
### 变更说明
<!-- 简要描述此 PR 的目的 -->
### 相关 Issue
<!-- 如果有的话,关联相关的 Issue -->
Closes #
### 测试
- [ ] 添加了新的单元测试
- [ ] 添加了新的集成测试
- [ ] 所有现有测试通过
- [ ] 手动测试通过
### 检查清单
- [ ] 代码遵循项目代码规范
- [ ] 更新了相关文档
- [ ] 没有引入新的警告
- [ ] 测试覆盖率没有下降
- [ ] 提交信息遵循规范
### 截图(如适用)
<!-- 添加相关截图 -->
### 额外信息
<!-- 其他需要说明的信息 -->
-96
View File
@@ -1,96 +0,0 @@
name: CI/CD Pipeline
on:
push:
branches:
- main
- develop
- 'feature/**'
- 'bugfix/**'
- 'hotfix/**'
- 'release/**'
pull_request:
branches:
- main
- develop
jobs:
validate:
name: Validate Code Quality And Tests
runs-on: ubuntu-latest
container: xiaoxia-ci-python:3.12
steps:
- name: Checkout code
run: |
python - <<'PY'
import os
import tarfile
import urllib.request
api_url = os.environ['GITHUB_API_URL']
repository = os.environ['GITHUB_REPOSITORY']
sha = os.environ['GITHUB_SHA']
token = os.environ.get('GITHUB_TOKEN', '')
archive_url = f"{api_url}/repos/{repository}/archive/{sha}.tar.gz"
request = urllib.request.Request(archive_url)
if token:
request.add_header('Authorization', f'token {token}')
with urllib.request.urlopen(request, timeout=120) as response:
with open('/tmp/repo.tar.gz', 'wb') as archive:
archive.write(response.read())
with tarfile.open('/tmp/repo.tar.gz', 'r:gz') as archive:
members = archive.getmembers()
top_level = members[0].name.split('/')[0] + '/'
for member in members:
member.name = member.name.removeprefix(top_level)
if member.name:
archive.extract(member, '.')
PY
- name: Verify CI environment
run: |
python --version
python -m pip --version
python -m black --version
python -m isort --version-number
python -m flake8 --version
bandit --version
pytest --version
echo "✅ Prebuilt CI environment is ready"
- name: Run code quality checks
run: |
python -m compileall -q alembic apps packages tests scripts
python -m black --check alembic apps packages tests scripts
python -m isort --check-only alembic apps packages tests scripts
python -m flake8 apps packages tests --count --statistics
- name: Run security scan
run: |
bandit -r apps packages -q
- name: Validate release scripts syntax
run: |
bash -n scripts/backup_postgres.sh
bash -n scripts/restore_postgres_plan.sh
bash -n scripts/init_production_env.sh
- name: Validate Alembic migrations
run: |
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas \
python -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql
test -s /tmp/alembic-upgrade.sql
grep -q "Running upgrade" /tmp/alembic-upgrade.sql
python scripts/check_schema_metadata.py
- name: Run tests
run: |
python -m pytest tests -q
- name: Build summary
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
run: |
echo "✅ Build completed successfully!"
echo "Branch: ${GITHUB_REF_NAME}"
echo "Commit: ${GITHUB_SHA}"
-74
View File
@@ -1,74 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
create-release:
name: Create Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
id: changelog
run: |
# Extract changelog for this version
VERSION=${GITHUB_REF#refs/tags/}
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Create Release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ steps.changelog.outputs.version }}
body: |
See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details.
draft: false
prerelease: false
build-and-push:
name: Build and Push Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: xiaoxia/saas
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
-59
View File
@@ -1,59 +0,0 @@
name: Security Scan
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
schedule:
# Run every Monday at 00:00 UTC
- cron: '0 0 * * 1'
jobs:
security-scan:
name: Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install safety bandit
- name: Check for known security vulnerabilities
run: |
pip install -r requirements.txt
safety check --json
- name: Run Bandit security linter
run: |
bandit -r packages/ apps/ -f json -o bandit-report.json || true
cat bandit-report.json
- name: Upload security reports
uses: actions/upload-artifact@v3
if: always()
with:
name: security-reports
path: |
bandit-report.json
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Dependency Review
uses: actions/dependency-review-action@v3
-40
View File
@@ -1,40 +0,0 @@
# Node / frontend
node_modules/
.next/
out/
dist/
coverage/
# Python / backend
.venv/
venv/
.venv-ci-root/
__pycache__/
.pytest_cache/
.mypy_cache/
.pytype/
ruff_cache/
*.pyc
# Env / secrets
.env
.env.local
.env.development
.env.production # 生产环境配置不提交(包含敏感信息)
!.env.example
!.env.staging # staging 配置可以提交
# OS / editor
.DS_Store
Thumbs.db
.vscode/
.idea/
# Logs / temp
*.log
tmp/
temp/
# Build / runtime artifacts
build/
.runtime/
-239
View File
@@ -1,239 +0,0 @@
# Changelog
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 产品化 - 完成
**开发时长:** 5 小时 54 分钟
**完成进度:** 50/68 (73.5%)
**代码量:** 20,500+ 行
**测试覆盖:** 85%+
#### Added
**认证系统:**
- 用户注册(邮箱验证)
- 用户登录(JWT + Session)
- 用户登出(单设备/所有设备)
- 邮箱验证
- 密码重置(邮件重置链接)
- JWT Service(access + refresh token,30分钟/30天)
- Password Hasher(bcrypt, cost=12)
- Session Store(Redis-based)
- Email Service(SMTP with templates)
**工作空间管理:**
- 创建工作空间
- 获取工作空间列表/详情
- 邀请成员(邮件邀请)
- 接受/拒绝邀请
- 移除成员
- 离开工作空间
- 修改成员角色
- 获取成员列表
**权限系统:**
- 基于角色的访问控制(RBAC)
- 4 种角色(Owner/Admin/Member/Viewer)
- 细粒度权限定义
- 权限检查中间件
- 数据隔离
**订阅系统:**
- 3 级订阅计划(Free/Pro/Enterprise)
- 升级订阅
- 取消订阅(降级到 Free)
- 自动配额调整
**配额系统:**
- 项目数量限制检查
- 存储空间限制检查
- 配额使用状态查询
- 警告级别(normal/warning/critical/exceeded)
- 存储使用量更新
**Repository 层:**
- UserRepository(InMemory + PostgreSQL)
- WorkspaceRepository(InMemory + PostgreSQL)
- WorkspaceMemberRepository(InMemory + PostgreSQL)
- WorkspaceInvitationRepository(InMemory + PostgreSQL)
- ProjectRepository(InMemory + PostgreSQL)
- 数据库连接池(ThreadedConnectionPool)
- 连接池上下文管理器(PooledConnection)
**API 层:**
- FastAPI 应用主入口
- 依赖注入容器
- 22 个 REST API 接口
- 6 个认证接口
- 13 个工作空间接口
- 3 个健康检查接口
- 认证中间件(JWT 验证)
- 权限中间件
- 全局异常处理
- 请求日志中间件
- 速率限制中间件
- 性能监控中间件
- API 版本管理中间件
- CORS 配置
**数据库:**
- PostgreSQL 表结构设计
- 初始化迁移脚本
- 索引优化
- 外键约束
- 配置切换(InMemory/PostgreSQL)
**部署:**
- Dockerfile
- docker-compose.yml
- 环境变量配置
- 健康检查端点(/health, /ready, /startup)
- Kubernetes 配置示例
**性能优化:**
- 数据库连接池(5-6x 性能提升)
- 慢请求监控(threshold: 1s)
- 慢查询检测(threshold: 100ms)
- 请求 ID 追踪
- 响应时间记录(X-Process-Time header)
**文档:**
- README(快速开始)
- API 使用指南
- 数据库迁移指南
- Docker 部署指南
- 数据库切换指南
- 连接池性能指南
- 性能监控指南
- 环境配置指南
- API 版本管理指南
- 健康检查指南
- 分页使用指南
- 生产部署检查清单
- 贡献指南
- Phase 4 设计文档
- Phase 4 进度报告
- Phase 4 最终交付总结
**工具和功能:**
- 通用分页器(PaginationParams, PaginatedResponse)
- 内存分页和数据库分页支持
#### Changed
- 所有 PostgreSQL Repository 使用连接池
- 优化数据库查询性能
- 改进错误响应格式(统一 JSON)
#### Deprecated
- N/A
#### Removed
- N/A
#### Fixed
- 修复路由注册顺序
- 修复健康检查端点注册
#### Security
- bcrypt 密码加密(cost=12)
- JWT token 签名验证
- SQL 注入防护(参数化查询)
- CORS 安全配置
- 速率限制(防止暴力破解)
- 敏感信息保护(.gitignore)
#### Performance
- 数据库连接池:5-6x 性能提升
- API 响应时间:< 50ms(平均)
- 数据库查询:< 10ms(平均)
- 并发支持:1000+ RPS
---
## [0.1.0] - 2026-06-16
### Phase 1-3: 基础功能
- 基础视频处理功能
- 素材库管理
- 项目管理
---
**说明:**
- [Added] 新增功能
- [Changed] 功能变更
- [Deprecated] 即将废弃的功能
- [Removed] 已删除的功能
- [Fixed] Bug 修复
- [Security] 安全相关更新
- [Performance] 性能优化
-43
View File
@@ -1,43 +0,0 @@
# Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior:
* The use of sexualized language or imagery
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at support@xiaoxia-saas.com.
All complaints will be reviewed and investigated promptly and fairly.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0.
-347
View File
@@ -1,347 +0,0 @@
# 小虾 SAAS 完整任务清单
**最后更新:** 2026-06-17 16:35 GMT+8
**整理者:** 小虾 🦐
---
## 📊 总览
| Phase | 任务总数 | 已完成 | 待完成 | 完成率 |
|-------|---------|--------|--------|--------|
| Phase 1-2 | 30 | 30 | 0 | 100% |
| Phase 3 | 5 | 2 | 3 | 40% |
| Phase 4 | 68 | 56 | 12 | 82.4% |
| Phase 5 | 15 | 0 | 15 | 0% |
| Phase 6 | 40 | 40 | 0 | 100% |
| Phase 7 | 30 | 0 | 30 | 0% |
| **总计** | **188** | **128** | **60** | **68.1%** |
---
## Phase 1-2: 基础架构与项目管理 (30/30) ✅
### 核心架构 (10/10) ✅
1. ✅ Clean Architecture 分层设计
2. ✅ Domain 层实现(实体和值对象)
3. ✅ Ports 层接口定义
4. ✅ Application 层用例实现
5. ✅ Adapters 层适配器实现
6. ✅ 双持久化实现(InMemory + PostgreSQL)
7. ✅ Docker Compose 开发环境
8. ✅ Alembic 数据库迁移
9. ✅ 依赖注入容器
10. ✅ 配置管理系统
### 核心业务对象 (10/10) ✅
11. ✅ User(用户实体)
12. ✅ Workspace(工作空间实体)
13. ✅ Project(项目实体)
14. ✅ AssetLibrary(素材库实体)
15. ✅ Asset(素材实体)
16. ✅ IngestJob(入库任务实体)
17. ✅ ClassificationJob(分类任务实体)
18. ✅ Task(任务管理实体)
19. ✅ Milestone(里程碑实体)
20. ✅ TaskIssue(任务问题实体)
### 核心业务流程 (5/5) ✅
21. ✅ 上传入库链路
22. ✅ 分类任务链路
23. ✅ 异步任务处理(Celery)
24. ✅ 任务状态跟踪
25. ✅ 里程碑管理流程
### 基础设施 (5/5) ✅
26. ✅ MinIO 文件存储
27. ✅ PostgreSQL 数据库
28. ✅ Redis 消息队列
29. ✅ Celery Worker
30. ✅ 集成测试(17个)
---
## Phase 3: 部署与备案 (2/5)
### 部署配置 (2/2) ✅
1. ✅ 服务器部署(47.98.113.167)
2. ✅ Nginx 反向代理(8088/8089)
### 备案与域名 (0/3) ⏳
3. ⏳ 域名备案通过(等待审核)
4. ⏳ HTTPS 证书申请
5. ⏳ 切换正式域名
---
## Phase 4: SAAS 产品化 (56/68)
### 认证系统 (9/9) ✅
1. ✅ JWT Service 实现
2. ✅ Password Hasher 实现
3. ✅ Redis Session Store
4. ✅ Email Service 实现
5. ✅ 用户注册 API
6. ✅ 邮箱验证 API
7. ✅ 用户登录 API
8. ✅ 用户登出 API
9. ✅ 密码重置 API
### 多租户系统 (9/9) ✅
10. ✅ 创建工作空间 API
11. ✅ 邀请成员 API
12. ✅ 接受/拒绝邀请 API
13. ✅ 移除成员 API
14. ✅ 离开工作空间 API
15. ✅ 更新成员角色 API
16. ✅ 列出工作空间 API
17. ✅ 工作空间详情 API
18. ✅ 列出成员 API
### 权限系统 (3/3) ✅
19. ✅ Permission Checker
20. ✅ RBAC 权限模型
21. ✅ 权限中间件
### 订阅系统 (4/8)
22. ✅ 订阅计划定义
23. ✅ 升级订阅 API
24. ✅ 取消订阅 API
25. ✅ 配额检查工具
26. ⏳ 支付宝 SDK 集成
27. ⏳ 微信支付 SDK 集成
28. ⏳ 账单生成系统
29. ⏳ 发票管理
### Repository 层 (13/13) ✅
30. ✅ UserRepository 接口
31. ✅ UserRepository InMemory 实现
32. ✅ UserRepository PostgreSQL 实现
33. ✅ WorkspaceRepository 接口
34. ✅ WorkspaceRepository InMemory 实现
35. ✅ WorkspaceRepository PostgreSQL 实现
36. ✅ WorkspaceMemberRepository 接口
37. ✅ WorkspaceMemberRepository InMemory 实现
38. ✅ WorkspaceMemberRepository PostgreSQL 实现
39. ✅ WorkspaceInvitationRepository 接口
40. ✅ WorkspaceInvitationRepository InMemory 实现
41. ✅ WorkspaceInvitationRepository PostgreSQL 实现
42. ✅ Database Migration 脚本
### API 层 (9/9) ✅
43. ✅ FastAPI 路由层
44. ✅ API 文档(Swagger)
45. ✅ 错误处理中间件
46. ✅ 参数验证
47. ✅ 认证中间件
48. ✅ 权限中间件
49. ✅ API 版本管理
50. ✅ 健康检查接口
51. ✅ CORS 配置
### 高级功能 (2/8)
52. ✅ Celery Worker 配置
53. ✅ Redis 缓存集成
54. ⏳ 文件上传(OSS)
55. ⏳ 搜索功能
56. ⏳ WebSocket 实时通信
57. ⏳ Webhook 支持
58. ⏳ 缓存优化
59. ⏳ 分布式锁
### 测试与 CI/CD (5/7)
60. ✅ GitHub Actions CI/CD
61. ✅ 单元测试(170个)
62. ✅ 集成测试
63. ✅ 连接池优化
64. ✅ 性能监控
65. ⏳ 性能测试
66. ⏳ 安全测试
### 文档 (6/6) ✅
67. ✅ API 文档编写
68. ✅ 部署文档
69. ✅ 开发文档
70. ✅ MIT 开源许可
71. ✅ README 完善
72. ✅ CONTRIBUTING 指南
---
## Phase 5: 支付与商业化 (0/15)
### 支付集成 (0/7)
1. ⏳ 支付宝 SDK 集成
2. ⏳ 微信支付 SDK 集成
3. ⏳ Stripe 国际支付
4. ⏳ 账单生成系统
5. ⏳ 发票管理
6. ⏳ 订阅自动续费
7. ⏳ 支付回调处理
### 商业功能 (0/8)
8. ⏳ 优惠券系统
9. ⏳ 推荐奖励
10. ⏳ 企业定制套餐
11. ⏳ 批量购买折扣
12. ⏳ 退款管理
13. ⏳ 发票开具
14. ⏳ 财务报表
15. ⏳ 营收统计
---
## Phase 6: 前端完善 (40/40) ✅
### 项目基础 (7/7) ✅
1. ✅ Vite + React + TypeScript 初始化
2. ✅ 配置 package.json
3. ✅ 基础布局组件
4. ✅ API 客户端封装
5. ✅ 路由配置
6. ✅ 设计系统配置
7. ✅ TypeScript 类型定义
### 认证系统 (5/5) ✅
8. ✅ 登录页面
9. ✅ 注册页面
10. ✅ 忘记密码页面
11. ✅ 重置密码页面
12. ✅ Token 管理和刷新
### 工作空间管理 (6/6) ✅
13. ✅ 工作空间列表页面
14. ✅ 工作空间详情页面
15. ✅ 成员列表和管理
16. ✅ 邀请成员功能
17. ✅ 权限矩阵展示
18. ✅ 工作空间设置
### 订阅管理 (5/5) ✅
19. ✅ 套餐选择页面
20. ✅ 升级流程页面
21. ✅ 配额展示组件
22. ✅ 账单页面
23. ✅ 订阅状态显示
### Admin 后台 (5/5) ✅
24. ✅ Dashboard 仪表盘
25. ✅ 用户管理页面
26. ✅ 用户操作功能
27. ✅ 系统监控页面
28. ✅ 日志查看器
### 个人中心 (4/4) ✅
29. ✅ 个人设置页面
30. ✅ 账号安全设置
31. ✅ 通知设置
32. ✅ Session 管理
### 测试与优化 (8/8) ✅
33. ✅ 单元测试
34. ✅ E2E 测试
35. ✅ 测试覆盖率报告
36. ✅ 性能优化
37. ✅ 构建优化
38. ✅ 依赖优化
39. ✅ CSS 优化
40. ✅ 生产构建配置
---
## Phase 7: 核心业务功能 (0/30)
### 视频处理 (0/10)
1. ⏳ 视频上传(断点续传)
2. ⏳ 视频转码(多格式)
3. ⏳ 视频剪辑(时间轴)
4. ⏳ 字幕生成(AI)
5. ⏳ 配音合成(TTS)
6. ⏳ 特效添加
7. ⏳ 批量处理
8. ⏳ 视频预览
9. ⏳ 视频导出
10. ⏳ 视频分享
### 素材管理 (0/10)
11. ⏳ 素材库优化
12. ⏳ 智能分类
13. ⏳ 标签管理
14. ⏳ 搜索优化
15. ⏳ 版本管理
16. ⏳ 素材回收站
17. ⏳ 素材分享
18. ⏳ 素材导入
19. ⏳ 素材导出
20. ⏳ 素材统计
### AI 能力 (0/10)
21. ⏳ 智能剪辑推荐
22. ⏳ 场景识别
23. ⏳ 人物追踪
24. ⏳ 语音识别
25. ⏳ 情感分析
26. ⏳ 自动字幕
27. ⏳ 自动配音
28. ⏳ 自动特效
29. ⏳ AI 脚本生成
30. ⏳ AI 视频摘要
---
## 📈 进度可视化
```
Phase 1-2: ████████████████████ 100% (30/30)
Phase 3: ████░░░░░░░░░░░░░░░░ 40% (2/5)
Phase 4: ████████████████░░░░ 82% (56/68)
Phase 5: ░░░░░░░░░░░░░░░░░░░░ 0% (0/15)
Phase 6: ████████████████████ 100% (40/40)
Phase 7: ░░░░░░░░░░░░░░░░░░░░ 0% (0/30)
-------------------------------------------
总体: █████████████░░░░░░░ 68% (128/188)
```
---
## 🎯 优先级排序
### 紧急且重要(立即执行)
1. Phase 3: 等待备案通过
2. Phase 4: 支付集成(4个任务)
3. Phase 4: 文件上传 OSS(1个任务)
### 重要但不紧急(近期规划)
4. Phase 5: 商业化功能(15个任务)
5. Phase 7: 视频处理核心功能(10个任务)
6. Phase 7: AI 能力集成(10个任务)
### 可选优化(后期考虑)
7. Phase 4: WebSocket、Webhook(2个任务)
8. Phase 4: 性能测试、安全测试(2个任务)
9. Phase 7: 素材管理优化(10个任务)
---
## 💡 关键决策记录
1. **Phase 1-2 已完全完成**,奠定了坚实的架构基础
2. **Phase 4 核心功能完成**,系统已生产就绪
3. **Phase 6 前端 100% 完成**,用户界面完整可用
4. **Phase 3 阻塞于备案**,等待工信部审核
5. **Phase 5 和 Phase 7 尚未启动**,等待商业化和核心功能开发
---
## 📞 说明
- ✅ = 已完成
- ⏳ = 待完成
- 🔄 = 进行中
**老大,这是完整准确的任务清单,共 188 个任务,已完成 128 个(68.1%)!**
---
**清单生成时间:** 2026-06-17 16:35 GMT+8
**整理者:** 小虾 🦐
-305
View File
@@ -1,305 +0,0 @@
# 贡献指南
感谢你对小虾 SaaS 项目的兴趣!
## 🚀 快速开始
### 1. Fork 和克隆
```bash
# Fork 项目到你的账号
# 然后克隆
git clone https://github.com/your-username/xiaoxia-saas.git
cd xiaoxia-saas
```
### 2. 设置开发环境
```bash
# 创建虚拟环境
python -m venv venv
source venv/bin/activate # Linux/Mac
# venv\Scripts\activate # Windows
# 安装依赖
pip install -r requirements.txt
# 使用内存数据库(无需 PostgreSQL)
echo "USE_IN_MEMORY_DB=true" > .env
# 启动开发服务器
uvicorn apps.api.main:app --reload
```
### 3. 运行测试
```bash
# 运行所有测试
pytest tests/ -v
# 运行单元测试
pytest tests/unit -v
# 生成覆盖率报告
pytest --cov=packages --cov-report=html
```
---
## 📝 提交规范
### Commit Message 格式
```
<type>(<scope>): <subject>
<body>
<footer>
```
**Type:**
- `feat`: 新功能
- `fix`: Bug 修复
- `docs`: 文档更新
- `style`: 代码格式(不影响功能)
- `refactor`: 重构
- `test`: 测试相关
- `chore`: 构建/工具相关
**示例:**
```
feat(auth): add password reset functionality
- Add RequestPasswordResetUseCase
- Send reset email with token
- Implement ResetPasswordUseCase
- Add unit tests
Closes #123
```
---
## 🏗️ 代码规范
### Python 代码风格
- 遵循 PEP 8
- 使用类型注解
- 函数和类添加 docstring
- 每个文件顶部添加模块说明
### 代码格式化
```bash
# 安装工具
pip install black isort
# 格式化代码
black packages/ apps/ tests/
isort packages/ apps/ tests/
```
### 架构原则
- 遵循 Clean Architecture
- 业务逻辑在 Application 层
- 基础设施在 Adapters 层
- 保持层次间依赖方向正确
---
## 🧪 测试要求
### 单元测试
- 所有新功能必须有单元测试
- 测试覆盖率不低于 80%
- 使用 pytest fixtures
- Mock 外部依赖
### 测试示例
```python
def test_create_workspace_success(use_case, mock_repo):
\"\"\"测试创建工作空间成功\"\"\"
request = CreateWorkspaceRequest(
name="Test",
owner_user_id="user-123",
)
response, error = use_case.execute(request)
assert error is None
assert response.name == "Test"
```
---
## 🔄 Pull Request 流程
### 1. 创建分支
```bash
# 从 main 创建功能分支
git checkout -b feat/your-feature-name
```
### 2. 开发和测试
```bash
# 编写代码
# 运行测试
pytest tests/ -v
# 提交
git add .
git commit -m "feat: your feature description"
```
### 3. 推送和创建 PR
```bash
# 推送到你的 fork
git push origin feat/your-feature-name
# 在 GitHub 上创建 Pull Request
```
### 4. PR 描述模板
```markdown
## 变更说明
简要描述此 PR 的目的
## 变更类型
- [ ] 新功能
- [ ] Bug 修复
- [ ] 文档更新
- [ ] 重构
- [ ] 其他
## 测试
- [ ] 添加了单元测试
- [ ] 所有测试通过
- [ ] 手动测试通过
## 截图(如适用)
添加相关截图
## 相关 Issue
Closes #issue_number
```
---
## 🐛 报告 Bug
### Bug 报告模板
```markdown
**描述**
清晰描述 bug
**复现步骤**
1. 进入 '...'
2. 点击 '...'
3. 滚动到 '...'
4. 看到错误
**期望行为**
描述期望发生什么
**实际行为**
描述实际发生了什么
**环境**
- OS: [e.g. Ubuntu 22.04]
- Python: [e.g. 3.12]
- 浏览器: [e.g. Chrome 120]
**额外信息**
添加任何其他相关信息
```
---
## 💡 功能建议
### 功能请求模板
```markdown
**功能描述**
简要描述建议的功能
**问题**
此功能解决什么问题?
**建议方案**
描述你期望的解决方案
**替代方案**
考虑过哪些替代方案?
**额外信息**
其他相关信息
```
---
## 📚 文档贡献
### 文档类型
- README 和快速开始
- API 使用指南
- 部署文档
- 故障排查
- 架构说明
### 文档规范
- 使用 Markdown 格式
- 代码示例使用代码块
- 添加适当的标题层级
- 包含实际可运行的示例
---
## 🎯 优先级
### 高优先级
- Bug 修复
- 安全漏洞修复
- 性能优化
- 核心功能增强
### 中优先级
- 新功能
- 代码重构
- 测试增强
- 文档改进
### 低优先级
- 代码风格调整
- 次要功能
- 实验性功能
---
## 📞 联系方式
- **GitHub Issues**: 报告 bug 和功能请求
- **Pull Requests**: 贡献代码
- **Email**: support@xiaoxia-saas.com
---
## 📄 许可证
贡献的代码将使用与项目相同的许可证。
---
感谢你的贡献!🎉
-16
View File
@@ -1,16 +0,0 @@
# 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.
FROM scratch
LABEL org.opencontainers.image.title="xiaoxia-saas-deprecated-root-dockerfile"
LABEL org.opencontainers.image.description="Use infra/docker/api.Dockerfile instead"
RUN false
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 小虾 SaaS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-380
View File
@@ -1,380 +0,0 @@
# 小虾 SaaS 项目全景 - 完整状态记录
> 最后更新:2026-06-16 22:16
> 这是项目的完整状态、规则、进度记录,确保不会遗忘任何事情
---
## 🎯 项目定位
新一代 SaaS 版小虾自动化剪辑系统,采用 Clean Architecture 重新设计。
**核心目标**:
- AI 视频自动化剪辑
- 多租户 SaaS 平台
- 项目推进管理系统
---
## ✅ 已完成(Phase 1 & 2)
### 核心架构
- ✅ Clean Architecture 分层(Domain → Ports → Application → Adapters)
- ✅ 双持久化实现(In-Memory 测试 + PostgreSQL 生产)
- ✅ Docker Compose 完整开发环境
- ✅ Alembic 数据库迁移
- ✅ Gitea CI/CD workflows(测试 + 部署)
### 核心业务对象
- ✅ User(用户)
- ✅ Workspace(工作空间)
- ✅ Project(项目)
- ✅ AssetLibrary(素材库:视频/音频)
- ✅ Asset(素材)
- ✅ IngestJob(入库任务)
- ✅ ClassificationJob(分类任务)
- ✅ **Task(任务管理)**
- ✅ **Milestone(里程碑)**
- ✅ **TaskIssue(任务问题/卡点)**
### 核心业务流程
- ✅ 上传 → 入库 → Asset 创建链路
- ✅ 分类任务链路
- ✅ 完整异步任务处理(Celery + Redis)
- ✅ **任务创建 → 状态更新 → 进度跟踪链路**
- ✅ **里程碑管理**
- ✅ **问题/卡点记录与解决**
### 基础设施
- ✅ MinIO 真实文件存储
- ✅ PostgreSQL 数据库
- ✅ Redis 消息队列
- ✅ Celery 异步任务
- ✅ Docker Compose 部署配置
- ✅ Nginx 反向代理(8088/8089 临时端口)
### 前端
- ✅ Next.js 14 + TypeScript + React 18
- ✅ 项目推进器前端页面(5 个页面)
- 首页
- 项目列表
- 任务详情
- 里程碑管理
- 问题卡点面板
- ✅ 3 个表单组件(创建任务/编辑任务/创建问题)
### 测试
- ✅ 17 个集成测试全绿
- 素材管理 8 个
- 项目管理 9 个
### 部署
- ✅ 服务器部署(47.98.113.167)
- ✅ 5 个容器运行(postgres/redis/api/worker/web)
- ✅ Nginx 配置完成(绕过备案限制)
- ✅ 临时访问地址:
- 前端:http://47.98.113.167:8088
- API 文档:http://47.98.113.167:8089/docs
---
## 🔄 进行中(Phase 3)
### 部署相关
- 🔄 **域名备案审核**(阻塞中)
- saas.xiaoxiajianji.com
- saas-api.xiaoxiajianji.com
- 等待工信部审核通过
- 🔄 **HTTPS 证书申请**(依赖备案)
- Let's Encrypt 证书
- 备案通过后申请
- 🔄 **推进器 API 路由问题**(技术问题)
- 症状:`/api/v1/project-management/tasks` 返回 404
- 根因:Docker 构建缓存导致旧代码进入容器
- 已诊断:`project_management.py` 的 router prefix 重复
- 修复方案:移除 `/api/v1` 前缀,只保留 `/project-management`
- 状态:代码已修改,但容器内未生效(缓存问题)
---
## 📋 待办任务(按优先级)
### Phase 3:部署与备案完成(目标:2026-06-30)
**URGENT - 阻塞项**
1. ⚠️ **修复推进器 API 路由**
- 方案:直接进入容器手动修改测试
- 或者:彻底清理 Docker 镜像重建
2. ⚠️ **等待备案通过**
- 无法加速,只能等待
**HIGH - 备案后立即执行**
3. 📝 切换到正式域名和 HTTPS
- 改回 80/443 端口
- 申请 Let's Encrypt 证书
- nginx 配置 HTTPS
4. 📝 PostgreSQL 生产环境切换
- 当前用 In-Memory
- 需切换到 PostgreSQL + 数据持久化验证
5. 📝 前端环境变量配置
- API 地址从临时端口改为 https://saas-api.xiaoxiajianji.com
### Phase 4:SAAS 产品化完成(目标:2026-07-15)
**URGENT - 商业化基础**
6. 🔐 认证与账号体系
- JWT 登录
- 注册 + 密码重置
- Session 管理
7. 🔐 多租户权限体系
- Workspace 级别权限控制
- 用户角色管理(Admin/Member/Viewer)
- 数据隔离
**HIGH - 商业化能力**
8. 💰 订阅与计费体系
- SaaS 订阅套餐(基础版/专业版/企业版)
- 支付接入(微信/支付宝)
- 账单管理
### Phase 5:AI 剪辑能力接入(目标:2026-08-01)
**URGENT - 核心价值**
9. 🤖 视频分类模型接入
- 替换占位分类逻辑
- 真实 AI 模型
**HIGH - 增值功能**
10. 🎬 自动剪辑能力
- 视频自动剪辑
- 转场特效
- 字幕生成
11. 🎙️ 配音合成能力
- AI 配音
- 音频混音
### Phase 2 收尾(低优先级)
**MEDIUM**
12. 📊 甘特图视图开发
- 项目推进器增加甘特图/时间线视图
13. 📤 数据导出功能
- 导出任务列表为 Excel/CSV
**LOW**
14. 🔧 批量操作 API
- 任务批量更新状态/优先级/删除接口
---
## 🎯 里程碑
| 里程碑 | 目标日期 | 状态 | 说明 |
|--------|----------|------|------|
| Phase 1: 核心平台层完成 | 2026-06-15 | ✅ 完成 | Clean Architecture + 核心业务对象 |
| Phase 2: 项目管理模块落地 | 2026-06-16 | ✅ 完成 | 任务/里程碑/问题管理 + 前后端 |
| Phase 3: 部署与备案完成 | 2026-06-30 | 🔄 进行中 | 生产部署 + HTTPS + 域名备案 |
| Phase 4: SAAS 产品化完成 | 2026-07-15 | 📋 待开始 | 多租户 + 权限 + 订阅计费 |
| Phase 5: AI 剪辑能力接入 | 2026-08-01 | 📋 待开始 | 视频分类 + 自动剪辑 + 配音 |
---
## 📐 技术架构
### 后端
- **语言**:Python 3.12
- **框架**:FastAPI
- **数据库**:PostgreSQL(生产)+ SQLite(测试)
- **缓存/队列**:Redis
- **异步任务**:Celery
- **ORM**:SQLAlchemy
- **迁移**:Alembic
- **存储**:MinIO(S3-compatible)
### 前端
- **框架**:Next.js 14
- **语言**:TypeScript
- **UI 库**:React 18
### 架构模式
- Clean Architecture
- Ports & Adapters (Hexagonal)
- Repository Pattern
- Use Case Pattern
### 基础设施
- **容器**:Docker + Docker Compose
- **Web 服务器**:Nginx
- **CI/CD**:Gitea Actions
- **部署**:自建服务器(阿里云 ECS)
---
## 🗂️ 关键目录
```
xiaoxia-saas/
├── packages/ # 共享业务逻辑包
│ ├── domain/ # 核心实体与规则
│ ├── application/ # 用例层
│ ├── ports/ # 接口定义
│ └── adapters/ # 接口实现
│ ├── in_memory/ # 内存实现(测试)
│ └── sqlalchemy_impl/ # PostgreSQL 实现
├── apps/ # 应用层
│ ├── api/ # FastAPI REST API
│ ├── worker/ # Celery 异步任务
│ └── web/ # Next.js 前端
├── infra/ # 基础设施配置
│ ├── docker/ # Docker Compose
│ ├── scripts/ # 部署脚本
│ ├── systemd/ # systemd 服务
│ └── nginx/ # Nginx 配置(待添加)
├── tests/ # 测试
│ ├── integration/ # 集成测试
│ └── e2e/ # 端到端测试(待添加)
├── alembic/ # 数据库迁移
├── scripts/ # 工具脚本
│ ├── init_tracker_data.py # 推进器数据初始化(Python)
│ └── init_tracker_data.ps1 # 推进器数据初始化(PowerShell)
└── docs/ # 文档
```
---
## 🔑 关键决策记录
### 架构决策
- ✅ 新 SaaS 与旧桌面版完全物理隔离
- ✅ 旧桌面版仅作为业务参考,不再作为未来主线
- ✅ 从第一天起就遵循 Clean Architecture
- ✅ 持久化层提供双实现(便于测试)
- ✅ 测试策略:集成测试优先,覆盖核心业务流程
- ✅ 数据库迁移从第一天起就版本化管理
### 部署决策
- ✅ CI/CD 基于 Gitea Actions + 自建 runner
- ✅ 服务器优先开发/部署策略
- ✅ 前端改为生产构建部署方案(非开发模式)
- ✅ 临时用 8088/8089 端口绕过备案限制
- ✅ 等备案通过后切换到 80/443 + HTTPS
### 工具链决策
- ✅ 缺工具直接装,不找替代方案(避免出错)
- ✅ Python 依赖装到 F 盘项目虚拟环境里
- ✅ 旧项目推进器(纯前端 HTML)已废弃
- ✅ 项目管理功能重新在 SAAS 里实现(后端 API + 前端 UI)
---
## 🔗 仓库信息
- **本地路径**:`F:\openclaw-saas`
- **远程仓库**:`xiaoxia-server:/var/lib/xiaoxia-ci/xiaoxia-saas.git`
- **服务器路径**:`/var/lib/xiaoxia-saas-staging/repo`
- **分支**:`main`
- **最新提交**:`c6f21d2 fix: remove duplicate api/v1 prefix in project-management routes`
---
## 📊 当前访问地址
### 临时地址(HTTP,绕过备案)
- **前端**:http://47.98.113.167:8088
- **API 文档**:http://47.98.113.167:8089/docs
- **API 端点**:http://47.98.113.167:8089/api/v1/
### 正式域名(备案通过后)
- **前端**:https://saas.xiaoxiajianji.com
- **API**:https://saas-api.xiaoxiajianji.com
---
## 🐛 已知问题
### 1. 推进器 API 路由 404(高优先级)
**症状**:
- 访问 `http://localhost:8000/api/v1/project-management/tasks` 返回 404
- OpenAPI 文档显示路由为 `/api/v1/api/v1/project-management/tasks`(重复前缀)
**根因**:
- `project_management.py` 里的 router 有 prefix `/api/v1/project-management`
- 主应用 `main.py` 又把 `api_router` 挂载到 `/api/v1`
- 导致前缀重复:`/api/v1` + `/api/v1/project-management`
**修复**:
- 已修改 `project_management.py` 的 prefix 为 `/project-management`
- 代码已提交:`c6f21d2`
- 服务器仓库已拉取最新代码
- **问题**:Docker 构建缓存顽固,容器内还是旧代码
**下一步**:
- 方案 A:直接进入容器修改文件测试
- 方案 B:完全清理 Docker 镜像层缓存再重建
- 方案 C:临时跳过,先完成其他任务
---
## 📝 开发规则
### Git 工作流
- ✅ 新功能开发在 `main` 分支(单人项目)
- ✅ 每个功能完成后及时提交
- ✅ 提交信息格式:`feat/fix/docs/refactor: 简短描述`
- ✅ 推送前确保本地测试通过
### 测试策略
- ✅ 集成测试优先(覆盖业务流程)
- ✅ 每个 Use Case 至少 1 个测试
- ✅ 新功能必须有测试
- ✅ 修复 bug 先写测试重现
### 部署流程
1. 本地开发 + 测试
2. 提交到 Git
3. 推送到服务器
4. 服务器自动触发 CI/CD(或手动)
5. Docker 重新构建
6. 容器重启
---
## 🔐 敏感信息(不要泄露)
- **服务器 IP**:47.98.113.167
- **SSH 别名**:xiaoxia-server
- **数据库密码**:(存储在 `.env` 文件,不提交到 Git)
- **MinIO 密钥**:(存储在 `.env` 文件)
---
## 🎓 技术债务
1. **In-Memory 持久化**:当前 API 用的还是 In-Memory,需切换到 PostgreSQL
2. **认证缺失**:当前无认证,所有接口公开
3. **错误处理**:部分接口错误处理不完善
4. **日志**:缺少结构化日志
5. **监控**:缺少性能监控和告警
6. **备份**:缺少数据库备份策略
---
## 📚 参考文档
- **项目总览**:`README.md`
- **当前状态**:`STATUS.md`(简化版)
- **部署指南**:`infra/docker/SERVER-DEPLOY.md`
- **CI/CD 说明**:`docs/CI-CD.md`
---
**以后每次新会话,先读这个文件快速恢复上下文。**
+159 -165
View File
@@ -1,70 +1,68 @@
# 小虾 SaaS - 自动化剪辑 SaaS 平台
# 小虾 SaaS - 自动化视频剪辑平台
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.115.0-009688.svg)](https://fastapi.tiangolo.com)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791.svg)](https://www.postgresql.org/)
一个功能完整、生产就绪的多租户 SaaS 平台,专为自动化视频剪辑服务设计。
自动化视频剪辑 SaaS 平台,支持素材上传、AI 分类、智能剪辑计划生成、自动化视频合成与成片管理。
---
## ✨ 核心特性
## ✨ 核心功能
### 🎬 视频剪辑主链路(Phase 7 已完成)
- 素材上传与管理
- AI 智能分类
- 自动化视频生成
- 成片下载与管理
### 🎬 视频剪辑主链路
- 素材上传(直传 OSS + 分片上传大文件,最大 2GB)
- AI 智能分类与质量评分
- 4 种剪辑模式:one_take / pip(画中画)/ voice_over(口播+B-roll)/ voice_pip
- 剪辑计划模板 + 智能生成
- 自动化视频合成任务(Celery 异步)
- 成片下载与审核管理
- 资产诊断(素材就绪度评估、缺口分析)
### 🔐 完整的认证系统
- JWT 认证(access + refresh token)
- 邮箱验证和密码重置
- Session 管理
### 🔐 认证系统
- JWT Bearer Token 认证
- 邮箱注册 + 邮箱验证
- 密码重置(邮箱找回)
- bcrypt 密码加密
### 🏢 多租户架构
- 工作空间隔离
- 团队成员管理
- 基于角色的权限控制(Owner/Admin/Member/Viewer)
- 邀请和审批流程
### 📋 项目管理
- 项目 CRUD + 共享
- 任务管理(创建/更新/状态流转/进度追踪)
- 里程碑管理
- 任务问题追踪
### 💸 订阅管理
- 3 级订阅计划(Free/Pro/Enterprise)
- 配额管理(项目数/存储空间)
- 升级和取消订阅
### ⚡ 高性能
- 数据库连接池(5-6x 性能提升)
- 请求日志和监控
- 慢查询检测
- 健康检查(Kubernetes 就绪)
### 📊 素材库管理
- 素材库创建与管理
- 素材上传、审核状态流转(pending_review → approved/rejected)
- 素材诊断(就绪度评分、缺口分析、智能视图)
---
## 🚀 快速开始
### 方式 1: Docker(推荐)
### 方式 1: Docker Compose(推荐)
```bash
# 1. 克隆仓库
git clone https://github.com/your-org/xiaoxia-saas.git
git clone https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git
cd xiaoxia-saas
# 2. 启动所有服务
# 2. 配置环境变量
cp .env.example .env
# 编辑 .env 填写数据库、Redis、OSS 等配置
# 3. 启动所有服务
docker-compose up -d
# 3. 访问 API 文档
# 4. 访问 API 文档
open http://localhost:8000/docs
```
就这么简单!🎉
### 方式 2: 本地开发
```bash
# 1. 克隆仓库
git clone https://github.com/your-org/xiaoxia-saas.git
git clone https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git
cd xiaoxia-saas
# 2. 创建虚拟环境
@@ -74,36 +72,90 @@ source venv/bin/activate # Windows: venv\Scripts\activate
# 3. 安装依赖
pip install -r requirements.txt
# 4. 启动开发服务器
# 4. 配置环境变量
cp .env.example .env
# 5. 启动 API 服务
uvicorn apps.api.main:app --reload
# 5. 访问 API 文档
# 6. 访问 API 文档
open http://localhost:8000/docs
```
---
## 📚 主要文档
## 📚 API 文档
### 快速入口
- **[API 主线清单](docs/API-MAINLINE.md)** - 所有 API endpoints 总览
- **[代码状态标注](docs/CODE-STATUS.md)** - 代码库导航指南
- **[API 使用指南](docs/API-GUIDE.md)** - API 详细用法
### 交互式文档
- **Swagger UI**: https://saas-api.xiaoxiajianji.com/docs
- **OpenAPI Schema**: https://saas-api.xiaoxiajianji.com/openapi.json
### Phase 7 交付
- **[Phase 7 进度](docs/PHASE7-PROGRESS.md)** - 核心视频剪辑业务已完成
- **[Phase 7 设计](docs/PHASE7-DESIGN.md)** - 设计文档
### 核心 API 路径
### 基础设施
- **[Docker 部署指南](docs/DOCKER-DEPLOYMENT.md)**
- **[CI/CD 文档](docs/CI-CD.md)**
- **[性能监控指南](docs/PERFORMANCE-MONITORING.md)**
- **[健康检查指南](docs/HEALTH-CHECKS.md)**
**认证** (`/api/v1/auth`)
### 开发规范
- **[Git 工作流](docs/GIT-WORKFLOW.md)**
- **[环境配置指南](docs/ENVIRONMENT-CONFIG.md)**
- **[贡献指南](CONTRIBUTING.md)**
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/register` | 用户注册 |
| POST | `/login` | 用户登录 |
| GET | `/me` | 获取当前用户信息 |
| POST | `/password/forgot` | 忘记密码 |
| POST | `/password/reset` | 重置密码 |
**视频剪辑主链路**
```
上传素材 → POST /api/v1/upload(直传)或 /api/v1/upload/chunk/init(分片)
↓
创建素材 → POST /api/v1/assets
↓
AI 分类 → POST /api/v1/classification-jobs
↓
生成剪辑计划 → POST /api/v1/projects/{id}/edit-plans/auto-generate
↓
创建生成任务 → POST /api/v1/generation/tasks/
↓
查询结果 → GET /api/v1/generation/tasks/{task_id}/results/
↓
获取成片 → GET /api/v1/generated-videos/{video_id}/download-url
```
**项目管理** (`/api/v1/project-management`)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET/POST | `/tasks` | 任务列表/创建 |
| PATCH | `/tasks/{id}` | 更新任务信息 |
| PATCH | `/tasks/{id}/status` | 更新任务状态 |
| PATCH | `/tasks/{id}/progress` | 更新任务进度 |
| GET/POST | `/milestones` | 里程碑列表/创建 |
| GET/POST | `/issues` | 问题列表/创建 |
| PATCH | `/issues/{id}/resolve` | 解决问题 |
**素材与上传**
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/v1/upload` | 直传素材(multipart/form-data) |
| POST | `/api/v1/upload/direct/prepare` | 准备 OSS 直传签名 |
| POST | `/api/v1/upload/direct/complete` | 确认直传完成 |
| POST | `/api/v1/upload/chunk/init` | 初始化分片上传 |
| POST | `/api/v1/upload/chunk/{id}/{index}` | 上传分片 |
| POST | `/api/v1/upload/chunk/{id}/complete` | 完成分片上传 |
| GET | `/api/v1/assets` | 素材列表 |
| PATCH | `/api/v1/assets/{id}/review` | 更新素材审核状态 |
| GET | `/api/v1/projects/{id}/asset-diagnosis` | 资产诊断 |
**成片管理** (`/api/v1/generated-videos`)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/` | 成片列表 |
| GET | `/{video_id}` | 成片详情 |
| GET | `/{video_id}/download-url` | 下载链接 |
| PATCH | `/{video_id}/review` | 审核状态 |
完整 API 列表请查看 [API 主线清单](docs/API-MAINLINE.md)
---
@@ -111,61 +163,49 @@ open http://localhost:8000/docs
```
小虾 SaaS
├── packages/ # 核心业务逻辑
│ ├── domain/ # 领域模型
│ ├── application/ # 用例
│ ├── ports/ # 接口定义
│ └── adapters/ # 适配器实现
├── packages/ # 核心业务逻辑(Clean Architecture)
│ ├── domain/ # 领域模型(dataclass)
│ ├── application/ # 用例(Use Cases)
│ ├── ports/ # 接口定义(抽象端口)
│ └── adapters/ # 适配器实现(SQLAlchemy、Redis、SMTP 等)
├── apps/ # 应用层
│ ├── api/ # FastAPI 应用
│ ├── web/ # React 前端
│ └── worker/ # Celery worker
├── migrations/ # 数据库迁移
│ ├── api/ # FastAPI 应用 + 路由 + Pydantic schemas
│ ├── web/ # React + Vite 前端
│ └── worker/ # Celery 异步任务(视频处理、分类等)
├── migrations/ # Alembic 数据库迁移
├── tests/ # 测试
│ ├── unit/ # 单元测试
│ └── integration/ # 集成测试
│ ├── integration/ # 集成测试
│ └── e2e/ # 端到端测试
└── docs/ # 文档
```
**设计模式:**
- Clean Architecture
- 依赖注入
- Repository 模式
- Clean Architecture(依赖方向:外层 → 内层)
- 依赖注入(FastAPI Depends)
- Repository 模式(通过 ports 抽象)
- Domain-Driven Design
---
## 🔑 核心 API 路径
## 🛠️ 技术栈
### 视频剪辑主链路(Phase 7)
**后端:**
- Python 3.12 + FastAPI 0.115.0
- PostgreSQL 16(生产)
- Redis 7(缓存 + Celery Broker)
- Celery(异步任务:视频处理、素材导入、分类)
- 阿里云 OSS(文件存储)
```
上传素材
↓
POST /api/v1/upload
**前端:**
- React 18 + TypeScript
- Vite(构建工具)
- Ant Design(UI 组件)
创建 Asset
↓
POST /api/v1/assets
发起分类
↓
POST /api/v1/classification-jobs
创建生成任务
↓
POST /api/v1/generation/tasks
查询生成结果
↓
GET /api/v1/generation/tasks/{task_id}/results
获取下载链接
↓
GET /api/v1/generated-videos/{video_id}/download-url
```
完整 API 列表请查看 [API 主线清单](docs/API-MAINLINE.md)
**部署:**
- Docker + Docker Compose
- Gitea + Gitea Actions(CI/CD)
- Nginx(反向代理)
---
@@ -185,51 +225,32 @@ pytest tests/integration -v
pytest --cov=packages --cov-report=html
```
**测试统计:**
- 单元测试: 17+ 个 ✅
- 集成测试: 6+ 个 ✅
- 测试覆盖率: 持续提升中
---
## 🛠️ 技术栈
**后端:**
- Python 3.12
- FastAPI 0.115.0
- PostgreSQL 16
- Redis 7
- Celery
- MinIO
**前端:**
- React 18
- TypeScript
- Vite
- Ant Design
**测试:**
- pytest
- pytest-asyncio
- pytest-cov
**部署:**
- Docker
- Docker Compose
- Gitea Actions
---
## 📊 当前状态
| 模块 | 状态 |
|------|------|
| Phase 7(视频剪辑主链) | ✅ 已完成 |
| CI/CD 稳定性 | ✅ 已修复 |
| 认证系统 | ✅ 完整 |
| 多租户架构 | ✅ 完整 |
| 订阅系统 | ✅ 完整 |
| 前端联调 | ✅ 完成 |
| 视频剪辑主链路(Phase 7) | ✅ 已完成 |
| 分片上传(最大 2GB) | ✅ 已完成 |
| 4 种剪辑模式 | ✅ 已完成 |
| 项目管理 + 任务追踪 | ✅ 已完成 |
| 资产诊断 | ✅ 已完成 |
| 认证系统(JWT) | ✅ 已完成 |
| CI/CD 流水线 | ✅ 运行中 |
| 前端界面(Vite) | ✅ 已完成 |
---
## 📄 更多文档
- [API 主线清单](docs/API-MAINLINE.md) - 全部端点总览
- [API 使用指南](docs/API-GUIDE.md) - 详细用法
- [代码状态标注](docs/CODE-STATUS.md) - 代码库导航
- [Docker 部署指南](docs/DOCKER-DEPLOYMENT.md)
- [CI/CD 文档](docs/CI-CD.md)
- [Git 工作流](docs/GIT-WORKFLOW.md)
- [环境配置指南](docs/ENVIRONMENT-CONFIG.md)
---
@@ -237,38 +258,11 @@ pytest --cov=packages --cov-report=html
欢迎贡献!请查看 [贡献指南](CONTRIBUTING.md)
1. Fork 项目
2. 创建分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'feat: Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建 Pull Request
**仓库地址**: https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas
---
## 📄 许可证
**License**: MIT
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情
---
## 📞 联系方式
- **问题反馈:** GitHub Issues
- **文档:** 参考 `docs/` 目录
---
## 🎉 致谢
感谢所有贡献者和使用者!
**开发团队:** 小虾 🦐
---
**⭐ 如果这个项目对你有帮助,请给一个 Star!**
---
**最后更新**: 2026-06-19
**当前版本**: Phase 7 完成
### 预览环境测试
此PR用于验证P1-4预览环境端到端部署流程,验证完成后将关闭。
-213
View File
@@ -1,213 +0,0 @@
# 小虾 SaaS - 开发路线图
## 🎯 愿景
构建一个**完整、高效、易用**的自动化视频剪辑 SaaS 平台。
---
## ✅ Phase 1-3: 基础功能(已完成)
- ✅ 基础视频处理功能
- ✅ 素材库管理
- ✅ 项目管理
- ✅ Clean Architecture 骨架
---
## 🚀 Phase 4: SAAS 产品化(进行中 - 77.9%)
**目标:** 将平台升级为真正的多租户商业化产品
### 已完成 (53/68)
- ✅ 用户认证系统
- ✅ 多租户管理
- ✅ 权限控制(RBAC)
- ✅ 订阅管理(基础)
- ✅ 完整的 Repository 层
- ✅ 22 个 API 接口
- ✅ 性能优化(5-6x 提升)
- ✅ 完整文档(19 篇)
- ✅ 开源设置(MIT)
### 进行中 (15/68)
- ⏳ 支付集成
- ⏳ 高级功能
- ⏳ 测试补充
- ⏳ CI/CD
---
## 📅 Phase 5: 支付与商业化(计划中)
**预计时间:** 2026-06-18 - 2026-06-30
### 支付集成
- [ ] 支付宝 SDK 集成
- [ ] 微信支付 SDK 集成
- [ ] Stripe 国际支付
- [ ] 账单生成系统
- [ ] 发票管理
- [ ] 订阅自动续费
- [ ] 支付回调处理
### 商业功能
- [ ] 优惠券系统
- [ ] 推荐奖励
- [ ] 企业定制套餐
- [ ] 批量购买折扣
---
## 🎨 Phase 6: 前端完善(计划中)
**预计时间:** 2026-07-01 - 2026-07-31
### 用户界面
- [ ] 用户注册/登录页面
- [ ] 工作空间管理界面
- [ ] 成员管理页面
- [ ] 订阅升级页面
- [ ] 账单和发票页面
- [ ] 个人设置页面
### 管理后台
- [ ] Admin Dashboard
- [ ] 用户管理
- [ ] 订阅管理
- [ ] 系统监控
- [ ] 数据分析
---
## 🔥 Phase 7: 核心业务功能(计划中)
**预计时间:** 2026-08-01 - 2026-09-30
### 视频处理
- [ ] 视频上传(断点续传)
- [ ] 视频转码(多格式)
- [ ] 视频剪辑(时间轴)
- [ ] 字幕生成(AI)
- [ ] 配音合成(TTS)
- [ ] 特效添加
- [ ] 批量处理
### 素材管理
- [ ] 素材库优化
- [ ] 智能分类
- [ ] 标签管理
- [ ] 搜索优化
- [ ] 版本管理
---
## 🚀 Phase 8: 高级功能(计划中)
**预计时间:** 2026-10-01 - 2026-12-31
### AI 能力
- [ ] 智能剪辑推荐
- [ ] 场景识别
- [ ] 人物追踪
- [ ] 语音识别
- [ ] 情感分析
### 协作功能
- [ ] 实时协作编辑
- [ ] 评论系统
- [ ] 版本对比
- [ ] 审批流程
- [ ] 导出模板
### 集成能力
- [ ] Webhook 系统
- [ ] OpenAPI 规范
- [ ] SDK(Python/JS)
- [ ] 第三方集成(YouTube/TikTok)
---
## 📊 Phase 9: 数据与运营(计划中)
**预计时间:** 2027-Q1
### 数据分析
- [ ] 用户行为分析
- [ ] 使用统计报表
- [ ] 性能监控大盘
- [ ] 业务指标追踪
### 运营工具
- [ ] 消息推送
- [ ] 邮件营销
- [ ] 活动管理
- [ ] 用户反馈系统
---
## 🌍 Phase 10: 国际化与扩展(计划中)
**预计时间:** 2027-Q2
### 国际化
- [ ] 多语言支持(中/英/日)
- [ ] 多时区处理
- [ ] 多货币支持
- [ ] 国际支付方式
### 扩展性
- [ ] 微服务拆分
- [ ] 消息队列(Kafka)
- [ ] 分布式存储
- [ ] CDN 加速
- [ ] 全球部署
---
## 🎯 关键里程碑
| 里程碑 | 时间 | 状态 |
|--------|------|------|
| Phase 4 核心完成 | 2026-06-17 | ✅ 完成 |
| Phase 5 支付集成 | 2026-06-30 | 🔄 计划中 |
| Phase 6 前端完善 | 2026-07-31 | 📅 计划中 |
| Phase 7 核心业务 | 2026-09-30 | 📅 计划中 |
| Phase 8 高级功能 | 2026-12-31 | 📅 计划中 |
| Phase 9 数据运营 | 2027-Q1 | 📅 计划中 |
| Phase 10 国际化 | 2027-Q2 | 📅 计划中 |
| **v2.0 正式发布** | **2027-Q3** | 📅 **目标** |
---
## 📈 成功指标
### 技术指标
- API 响应时间 < 50ms ✅
- 测试覆盖率 > 85% ✅
- 代码质量评分 > 90% ✅
- 系统可用性 > 99.9% 🎯
### 业务指标
- 注册用户 > 10,000
- 付费用户 > 1,000
- 月收入 > ¥100,000
- 用户满意度 > 4.5/5
---
## 🤝 贡献
我们欢迎社区贡献!
- **报告 Bug:** GitHub Issues
- **功能建议:** GitHub Discussions
- **代码贡献:** Pull Requests
查看 [贡献指南](CONTRIBUTING.md)
---
**路线图版本:** v1.0
**最后更新:** 2026-06-17
**负责人:** 小虾 🦐
-87
View File
@@ -1,87 +0,0 @@
# Security Policy
## Supported Versions
We release patches for security vulnerabilities in the following versions:
| Version | Supported |
| ------- | ------------------ |
| 1.0.x | :white_check_mark: |
| < 1.0 | :x: |
## Reporting a Vulnerability
We take the security of 小虾 SaaS seriously. If you believe you have found a security vulnerability, please report it to us as described below.
### Please do NOT:
- Open a public GitHub issue about the vulnerability
- Discuss the vulnerability publicly (Twitter, blog posts, etc.)
### Please DO:
1. **Email us directly:** security@xiaoxia-saas.com
2. **Include the following information:**
- Type of vulnerability
- Full path to the source file(s) related to the vulnerability
- Location of the affected code (tag/branch/commit)
- Step-by-step instructions to reproduce the issue
- Proof-of-concept or exploit code (if possible)
- Impact of the vulnerability
### What to expect:
- We will acknowledge your email within 48 hours
- We will provide a more detailed response within 7 days
- We will work on a fix and release a patch ASAP
- We will credit you in the release notes (if you wish)
## Security Best Practices
When deploying 小虾 SaaS:
1. **Change all default secrets:**
- `JWT_SECRET_KEY` (minimum 32 characters)
- Database passwords
- Redis passwords
2. **Use HTTPS in production:**
- Configure SSL certificates
- Enable HTTPS redirect
3. **Enable rate limiting:**
- Uncomment `RateLimitMiddleware` in production
- Configure appropriate limits
4. **Regular updates:**
- Keep dependencies up to date
- Apply security patches promptly
5. **Database security:**
- Use strong passwords
- Limit network access
- Enable SSL connections
## Security Features
小虾 SaaS includes:
- ✅ bcrypt password hashing (cost=12)
- ✅ JWT token signing and validation
- ✅ SQL injection protection (parameterized queries)
- ✅ XSS protection (input validation)
- ✅ CORS configuration
- ✅ Rate limiting
- ✅ Session management
## Disclosure Policy
When we receive a security bug report, we will:
1. Confirm the problem and determine affected versions
2. Audit code to find similar problems
3. Prepare fixes for all supported versions
4. Release patches as soon as possible
5. Publicly disclose the vulnerability
Thank you for helping keep 小虾 SaaS and our users safe!
-105
View File
@@ -1,105 +0,0 @@
# 小虾 SaaS - 项目状态
**最后更新:** 2026-06-17 09:08 GMT+8
## 🎉 Phase 4: SAAS 产品化 - 圆满完成!
**进度:** 56/68 (82.4%) 🎊
**状态:** ✅ **生产就绪,可立即使用**
**开发时长:** 6 小时 8 分钟
**最终提交:** 60 次
---
## 🚀 系统能力(100% 生产就绪)
### 核心功能
- ✅ 用户认证(JWT + Session)
- ✅ 多租户工作空间
- ✅ 权限控制(RBAC)
- ✅ 订阅管理
- ✅ 配额限制
- ✅ 22 个 API 接口
### 技术特性
- ✅ Clean Architecture
- ✅ 数据库连接池(5-6x 性能)
- ✅ 健康检查(K8s 就绪)
- ✅ API 版本管理
- ✅ 通用分页器
- ✅ 完整监控
### 质量保证
- ✅ 170 个单元测试
- ✅ 85%+ 测试覆盖率
- ✅ 21 篇完整文档
- ✅ MIT 开源许可
---
## 📊 最终统计
**代码量:** 22,000+ 行
**API 接口:** 22 个
**单元测试:** 170 个
**文档:** 21 篇
**提交次数:** 60 次
**开发时长:** 6 小时 8 分钟
---
## 💰 价值成就
**节省成本:** ¥200,000
**节省时间:** 99.5% (4 个月 → 6 小时)
**性能提升:** 5-6x
**质量等级:** 企业级
---
## 🎯 可立即使用
```bash
# 一键启动
docker-compose up -d
# 访问文档
open http://localhost:8000/docs
```
**系统现在可以:**
- ✅ 部署到生产环境
- ✅ 开始商业运营
- ✅ 开源社区贡献
- ✅ MVP 产品验证
---
## 📅 未来计划
- Phase 5: 支付集成
- Phase 6: 前端完善
- Phase 7: 核心业务功能
- Phase 8: AI 能力
查看 [ROADMAP.md](ROADMAP.md)
---
## 📚 完整文档
查看 `docs/` 目录获取:
- 快速开始指南
- API 使用文档
- 部署指南
- 性能优化指南
- 21 篇完整技术文档
---
🎉 **Phase 4 圆满完成!感谢老大的支持!** 🎉
---
**项目地址:** https://github.com/your-org/xiaoxia-saas
**开发团队:** 小虾 🦐
-42
View File
@@ -1,42 +0,0 @@
# Alembic Config file
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql://postgres:postgres@localhost:5432/xiaoxia_saas
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
-23
View File
@@ -1,23 +0,0 @@
# Alembic Migrations
This directory contains database migration scripts managed by Alembic.
## Usage
```bash
# Apply all pending migrations
alembic upgrade head
# Rollback one migration
alembic downgrade -1
# Show current revision
alembic current
# Show migration history
alembic history
```
## Current Migrations
- `001_initial_schema.py` - Initial database schema (projects, asset_libraries, assets, ingest_jobs)
-85
View File
@@ -1,85 +0,0 @@
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
# Import your models' Base here
from packages.adapters.sqlalchemy_impl.models import Base
# this is the Alembic Config object, which provides
# 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:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
-26
View File
@@ -1,26 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -1,331 +0,0 @@
"""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 @@
"""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")
BIN
View File
Binary file not shown.
-15
View File
@@ -1,15 +0,0 @@
# xiaoxia-saas API
## 结构
- `app/core/`:配置与基础能力
- `app/api/`:路由组织
- `app/schemas/`:请求响应模型
- `app/dependencies.py`:依赖注入入口
- `main.py`:FastAPI 启动入口
## 当前可用接口
- `GET /api/health`
- `GET /api/projects?workspace_id=...`
- `POST /api/projects`
-3
View File
@@ -1,3 +0,0 @@
from .main import app, create_app
__all__ = ["app", "create_app"]
-1
View File
@@ -1 +0,0 @@
"""API application package."""
-1
View File
@@ -1 +0,0 @@
"""API package."""
-71
View File
@@ -1,71 +0,0 @@
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.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.projects import router as projects_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(
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(
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",
tags=["项目推进管理"],
)
-17
View File
@@ -1,17 +0,0 @@
"""API route package.
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.
"""
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)
__all__ = ["api_router", "health_router"]
@@ -1,86 +0,0 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_asset_library_repository, get_project_repository, get_workspace_member_repository
from app.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,
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=[_to_asset_library_response(item) for item in items])
@router.post("", response_model=AssetLibraryResponse)
def create_asset_library(
request: CreateAssetLibraryRequest,
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(
workspace_id=request.workspace_id,
project_id=request.project_id,
name=request.name,
kind=AssetLibraryKind(request.kind),
)
)
return _to_asset_library_response(item)
-106
View File
@@ -1,106 +0,0 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.api.routes.permissions import require_workspace_member
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
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,
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=[_to_asset_response(item) for item in items])
@router.post("", response_model=AssetResponse)
def create_asset(
request: CreateAssetRequest,
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(
workspace_id=request.workspace_id,
project_id=request.project_id,
library_id=request.library_id,
name=request.name,
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 _to_asset_response(item)
-230
View File
@@ -1,230 +0,0 @@
"""
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 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.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,
ResetPasswordRequest,
ResetPasswordUseCase,
)
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=["认证"])
class RegisterRequest(BaseModel):
email: EmailStr
password: str
username: str
display_name: Optional[str] = None
class RegisterResponse(BaseModel):
user_id: str
email: str
username: str
display_name: str
message: str
class LoginRequest(BaseModel):
email: EmailStr
password: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user_id: str
email: str
username: str
display_name: str
expires_in: int
class CurrentUserResponse(BaseModel):
user_id: str
email: str
username: str
display_name: str
email_verified: bool
class PasswordResetRequestModel(BaseModel):
email: EmailStr
class ResetPasswordModel(BaseModel):
token: str
new_password: str
class VerifyEmailRequestModel(BaseModel):
token: str
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(
RegisterUseCaseRequest(
email=request.email,
password=request.password,
username=request.username,
display_name=request.display_name or request.username,
)
)
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,
message="注册成功!",
)
@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(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,
user_id=response.user_id,
email=response.email,
username=response.username,
display_name=response.display_name,
expires_in=response.expires_in,
)
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),
):
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)
)
if not success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "密码重置失败")
return MessageResponse(message="密码重置成功")
@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,
)
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,63 +0,0 @@
from datetime import datetime, timezone
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 fastapi import APIRouter, Depends, HTTPException
from packages.application import (
SubmitClassificationJobCommand,
SubmitClassificationJobUseCase,
)
router = APIRouter()
@router.get("/{job_id}", response_model=ClassificationJobResponse)
def get_classification_job(
job_id: str,
classification_job_repository: Any = Depends(get_classification_job_repository),
) -> ClassificationJobResponse:
job = classification_job_repository.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"ClassificationJob {job_id} not found")
return ClassificationJobResponse(
id=job.id,
workspace_id=job.workspace_id,
project_id=job.project_id,
asset_id=job.asset_id,
status=job.status.value,
classification=job.classification,
confidence=job.confidence,
error_message=job.error_message,
)
@router.post("", response_model=ClassificationJobResponse)
def submit_classification_job(
request: SubmitClassificationJobRequest,
classification_job_repository: Any = Depends(get_classification_job_repository),
) -> ClassificationJobResponse:
use_case = SubmitClassificationJobUseCase(classification_job_repository)
job = use_case.execute(
SubmitClassificationJobCommand(
workspace_id=request.workspace_id,
project_id=request.project_id,
asset_id=request.asset_id,
)
)
celery_app.send_task("worker.classify_asset", args=[job.id])
return ClassificationJobResponse(
id=job.id,
workspace_id=job.workspace_id,
project_id=job.project_id,
asset_id=job.asset_id,
status=job.status.value,
classification=job.classification,
confidence=job.confidence,
error_message=job.error_message,
)
@@ -1,90 +0,0 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.api.routes.permissions import require_workspace_member
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,
)
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,
)
@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.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)
-127
View File
@@ -1,127 +0,0 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.api.routes.permissions import require_workspace_member
from app.core.celery_app import celery_app
from app.dependencies import (
get_asset_library_repository,
get_generated_video_repository,
get_generation_task_repository,
get_project_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,
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,
)
@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),
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")
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,
voice_library_id=request.voice_library_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])
-119
View File
@@ -1,119 +0,0 @@
from datetime import datetime
import psycopg2
import redis
from app.config import settings
from fastapi import APIRouter, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel
router = APIRouter(tags=["Health"])
@router.get("/health", status_code=status.HTTP_200_OK)
async def health_check():
return {
"status": "healthy",
"timestamp": datetime.utcnow().isoformat(),
"version": settings.APP_VERSION,
}
@router.get("/ready", status_code=status.HTTP_200_OK)
async def readiness_check():
checks = {
"database": await _check_database(),
"redis": await _check_redis(),
}
all_healthy = all(check["status"] == "healthy" for check in checks.values())
response = {
"status": "ready" if all_healthy else "not_ready",
"timestamp": datetime.utcnow().isoformat(),
"checks": checks,
}
if not all_healthy:
return JSONResponse(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content=response)
return response
@router.get("/startup", status_code=status.HTTP_200_OK)
async def startup_check():
checks = {
"database": await _check_database(),
"migrations": await _check_migrations(),
}
all_ready = all(check["status"] == "healthy" for check in checks.values())
response = {
"status": "started" if all_ready else "starting",
"timestamp": datetime.utcnow().isoformat(),
"checks": checks,
}
if not all_ready:
return JSONResponse(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content=response)
return response
async def _check_database() -> dict:
if settings.USE_IN_MEMORY_DB:
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",
}
except Exception as error:
return {
"status": "unhealthy",
"type": "postgresql",
"message": f"Database connection failed: {error}",
}
async def _check_redis() -> dict:
try:
client = redis.from_url(settings.REDIS_URL, socket_connect_timeout=3)
client.ping()
client.close()
return {
"status": "healthy",
"type": "redis",
"message": "Redis connection successful",
}
except Exception as 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",
}
try:
conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3)
with conn.cursor() as cur:
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:
return {"status": "healthy", "message": "Database migrations applied"}
return {"status": "unhealthy", "message": f"Missing tables, found {count}/5"}
except Exception as error:
return {"status": "unhealthy", "message": f"Migration check failed: {error}"}
-59
View File
@@ -1,59 +0,0 @@
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 fastapi import APIRouter, Depends
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
router = APIRouter()
@router.get("/{job_id}", response_model=IngestJobResponse)
def get_ingest_job(
job_id: str,
ingest_job_repository: Any = Depends(get_ingest_job_repository),
) -> IngestJobResponse:
job = ingest_job_repository.get(job_id)
if job is None:
raise ValueError(f"IngestJob {job_id} not found")
return IngestJobResponse(
id=job.id,
workspace_id=job.workspace_id,
project_id=job.project_id,
library_id=job.library_id,
storage_key=job.storage_key,
status=job.status.value,
error_message=job.error_message,
result_asset_id=job.result_asset_id,
)
@router.post("", response_model=IngestJobResponse)
def submit_ingest_job(
request: SubmitIngestJobRequest,
ingest_job_repository: Any = Depends(get_ingest_job_repository),
) -> IngestJobResponse:
use_case = SubmitIngestJobUseCase(ingest_job_repository)
job = use_case.execute(
SubmitIngestJobCommand(
workspace_id=request.workspace_id,
project_id=request.project_id,
library_id=request.library_id,
storage_key=request.storage_key,
)
)
celery_app.send_task("worker.ingest_asset", args=[job.id])
return IngestJobResponse(
id=job.id,
workspace_id=job.workspace_id,
project_id=job.project_id,
library_id=job.library_id,
storage_key=job.storage_key,
status=job.status.value,
error_message=job.error_message,
result_asset_id=job.result_asset_id,
)
-16
View File
@@ -1,16 +0,0 @@
from __future__ import annotations
from app.auth import AuthenticatedUser
from fastapi import HTTPException
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
def require_workspace_member(
workspace_id: str,
authenticated_user: AuthenticatedUser,
workspace_member_repository: WorkspaceMemberRepository,
) -> None:
member = workspace_member_repository.find_by_workspace_and_user(workspace_id, authenticated_user.user.id)
if member is None:
raise HTTPException(status_code=403, detail="Workspace access denied")
@@ -1,486 +0,0 @@
"""项目管理 API 路由"""
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from packages.adapters.sqlite_tracker.project_management_repositories import (
SQLiteMilestoneRepository,
SQLiteTaskIssueRepository,
SQLiteTaskRepository,
)
from packages.application.get_task_detail_use_case import GetTaskDetailUseCase
from packages.application.project_management_use_cases import (
CreateMilestoneUseCase,
CreateTaskIssueUseCase,
CreateTaskUseCase,
ListProjectMilestonesUseCase,
ListProjectTasksUseCase,
ListTaskIssuesUseCase,
ResolveTaskIssueUseCase,
UpdateTaskProgressUseCase,
UpdateTaskStatusUseCase,
)
from packages.application.update_task_use_case import UpdateTaskUseCase
from packages.domain import TaskPriority, TaskStatus
router = APIRouter()
# 使用 SQLite tracker.db
_task_repo = SQLiteTaskRepository()
_milestone_repo = SQLiteMilestoneRepository()
_issue_repo = SQLiteTaskIssueRepository()
def get_task_repo():
return _task_repo
def get_milestone_repo():
return _milestone_repo
def get_issue_repo():
return _issue_repo
# ========== Request/Response Models ==========
class CreateTaskRequest(BaseModel):
project_id: str
workspace_id: str
name: str
description: str = ""
priority: TaskPriority = TaskPriority.MEDIUM
parent_task_id: str = ""
assignee_user_id: str = ""
class TaskResponse(BaseModel):
id: str
project_id: str
workspace_id: str
name: str
description: str
status: TaskStatus
priority: TaskPriority
parent_task_id: str
assignee_user_id: str
progress: float
planned_start_date: datetime | None
planned_end_date: datetime | None
actual_start_date: datetime | None
actual_end_date: datetime | None
tags: list[str]
created_at: datetime
updated_at: datetime
class UpdateTaskRequest(BaseModel):
name: str | None = None
description: str | None = None
priority: str | None = None
assignee_user_id: str | None = None
class UpdateTaskStatusRequest(BaseModel):
status: TaskStatus
class UpdateTaskProgressRequest(BaseModel):
progress: Annotated[float, Field(ge=0, le=100)]
class CreateMilestoneRequest(BaseModel):
project_id: str
workspace_id: str
name: str
description: str = ""
class MilestoneResponse(BaseModel):
id: str
project_id: str
workspace_id: str
name: str
description: str
target_date: datetime | None
completed: bool
completed_at: datetime | None
created_at: datetime
updated_at: datetime
class CreateTaskIssueRequest(BaseModel):
task_id: str
project_id: str
workspace_id: str
title: str
description: str = ""
created_by_user_id: str = ""
class TaskIssueResponse(BaseModel):
id: str
task_id: str
project_id: str
workspace_id: str
title: str
description: str
resolved: bool
resolved_at: datetime | None
created_by_user_id: str
created_at: datetime
updated_at: datetime
# ========== Task Endpoints ==========
@router.post("/tasks", response_model=TaskResponse)
def create_task(
req: CreateTaskRequest,
task_repo=Depends(get_task_repo),
):
"""创建任务"""
use_case = CreateTaskUseCase(task_repo)
task = use_case.execute(
project_id=req.project_id,
workspace_id=req.workspace_id,
name=req.name,
description=req.description,
priority=req.priority,
parent_task_id=req.parent_task_id,
assignee_user_id=req.assignee_user_id,
)
return TaskResponse(
id=task.id,
project_id=task.project_id,
workspace_id=task.workspace_id,
name=task.name,
description=task.description,
status=task.status,
priority=task.priority,
parent_task_id=task.parent_task_id,
assignee_user_id=task.assignee_user_id,
progress=task.progress,
planned_start_date=task.planned_start_date,
planned_end_date=task.planned_end_date,
actual_start_date=task.actual_start_date,
actual_end_date=task.actual_end_date,
tags=task.tags,
created_at=task.created_at,
updated_at=task.updated_at,
)
@router.get("/tasks", response_model=list[TaskResponse])
def list_tasks(
project_id: str,
task_repo=Depends(get_task_repo),
):
"""获取项目任务列表"""
use_case = ListProjectTasksUseCase(task_repo)
tasks = use_case.execute(project_id)
return [
TaskResponse(
id=t.id,
project_id=t.project_id,
workspace_id=t.workspace_id,
name=t.name,
description=t.description,
status=t.status,
priority=t.priority,
parent_task_id=t.parent_task_id,
assignee_user_id=t.assignee_user_id,
progress=t.progress,
planned_start_date=t.planned_start_date,
planned_end_date=t.planned_end_date,
actual_start_date=t.actual_start_date,
actual_end_date=t.actual_end_date,
tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at,
)
for t in tasks
]
@router.get("/tasks/{task_id}", response_model=TaskResponse)
def get_task(
task_id: str,
task_repo=Depends(get_task_repo),
):
"""获取任务详情"""
use_case = GetTaskDetailUseCase(task_repo)
try:
task = use_case.execute(task_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return TaskResponse(
id=task.id,
project_id=task.project_id,
workspace_id=task.workspace_id,
name=task.name,
description=task.description,
status=task.status,
priority=task.priority,
parent_task_id=task.parent_task_id,
assignee_user_id=task.assignee_user_id,
progress=task.progress,
planned_start_date=task.planned_start_date,
planned_end_date=task.planned_end_date,
actual_start_date=task.actual_start_date,
actual_end_date=task.actual_end_date,
tags=task.tags,
created_at=task.created_at,
updated_at=task.updated_at,
)
@router.patch("/tasks/{task_id}", response_model=TaskResponse)
def update_task(
task_id: str,
req: UpdateTaskRequest,
task_repo=Depends(get_task_repo),
):
"""更新任务基本信息"""
use_case = UpdateTaskUseCase(task_repo)
try:
task = use_case.execute(
task_id=task_id,
name=req.name,
description=req.description,
priority=req.priority,
assignee_user_id=req.assignee_user_id,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return TaskResponse(
id=task.id,
project_id=task.project_id,
workspace_id=task.workspace_id,
name=task.name,
description=task.description,
status=task.status,
priority=task.priority,
parent_task_id=task.parent_task_id,
assignee_user_id=task.assignee_user_id,
progress=task.progress,
planned_start_date=task.planned_start_date,
planned_end_date=task.planned_end_date,
actual_start_date=task.actual_start_date,
actual_end_date=task.actual_end_date,
tags=task.tags,
created_at=task.created_at,
updated_at=task.updated_at,
)
@router.patch("/tasks/{task_id}/status", response_model=TaskResponse)
def update_task_status(
task_id: str,
req: UpdateTaskStatusRequest,
task_repo=Depends(get_task_repo),
):
"""更新任务状态"""
use_case = UpdateTaskStatusUseCase(task_repo)
try:
task = use_case.execute(task_id, req.status)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return TaskResponse(
id=task.id,
project_id=task.project_id,
workspace_id=task.workspace_id,
name=task.name,
description=task.description,
status=task.status,
priority=task.priority,
parent_task_id=task.parent_task_id,
assignee_user_id=task.assignee_user_id,
progress=task.progress,
planned_start_date=task.planned_start_date,
planned_end_date=task.planned_end_date,
actual_start_date=task.actual_start_date,
actual_end_date=task.actual_end_date,
tags=task.tags,
created_at=task.created_at,
updated_at=task.updated_at,
)
@router.patch("/tasks/{task_id}/progress", response_model=TaskResponse)
def update_task_progress(
task_id: str,
req: UpdateTaskProgressRequest,
task_repo=Depends(get_task_repo),
):
"""更新任务进度"""
use_case = UpdateTaskProgressUseCase(task_repo)
try:
task = use_case.execute(task_id, req.progress)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return TaskResponse(
id=task.id,
project_id=task.project_id,
workspace_id=task.workspace_id,
name=task.name,
description=task.description,
status=task.status,
priority=task.priority,
parent_task_id=task.parent_task_id,
assignee_user_id=task.assignee_user_id,
progress=task.progress,
planned_start_date=task.planned_start_date,
planned_end_date=task.planned_end_date,
actual_start_date=task.actual_start_date,
actual_end_date=task.actual_end_date,
tags=task.tags,
created_at=task.created_at,
updated_at=task.updated_at,
)
# ========== Milestone Endpoints ==========
@router.post("/milestones", response_model=MilestoneResponse)
def create_milestone(
req: CreateMilestoneRequest,
milestone_repo=Depends(get_milestone_repo),
):
"""创建里程碑"""
use_case = CreateMilestoneUseCase(milestone_repo)
milestone = use_case.execute(
project_id=req.project_id,
workspace_id=req.workspace_id,
name=req.name,
description=req.description,
)
return MilestoneResponse(
id=milestone.id,
project_id=milestone.project_id,
workspace_id=milestone.workspace_id,
name=milestone.name,
description=milestone.description,
target_date=milestone.target_date,
completed=milestone.completed,
completed_at=milestone.completed_at,
created_at=milestone.created_at,
updated_at=milestone.updated_at,
)
@router.get("/milestones", response_model=list[MilestoneResponse])
def list_milestones(
project_id: str,
milestone_repo=Depends(get_milestone_repo),
):
"""获取项目里程碑列表"""
use_case = ListProjectMilestonesUseCase(milestone_repo)
milestones = use_case.execute(project_id)
return [
MilestoneResponse(
id=m.id,
project_id=m.project_id,
workspace_id=m.workspace_id,
name=m.name,
description=m.description,
target_date=m.target_date,
completed=m.completed,
completed_at=m.completed_at,
created_at=m.created_at,
updated_at=m.updated_at,
)
for m in milestones
]
# ========== Task Issue Endpoints ==========
@router.post("/issues", response_model=TaskIssueResponse)
def create_issue(
req: CreateTaskIssueRequest,
issue_repo=Depends(get_issue_repo),
):
"""创建任务问题"""
use_case = CreateTaskIssueUseCase(issue_repo)
issue = use_case.execute(
task_id=req.task_id,
project_id=req.project_id,
workspace_id=req.workspace_id,
title=req.title,
description=req.description,
created_by_user_id=req.created_by_user_id,
)
return TaskIssueResponse(
id=issue.id,
task_id=issue.task_id,
project_id=issue.project_id,
workspace_id=issue.workspace_id,
title=issue.title,
description=issue.description,
resolved=issue.resolved,
resolved_at=issue.resolved_at,
created_by_user_id=issue.created_by_user_id,
created_at=issue.created_at,
updated_at=issue.updated_at,
)
@router.get("/issues", response_model=list[TaskIssueResponse])
def list_issues(
task_id: str,
issue_repo=Depends(get_issue_repo),
):
"""获取任务问题列表"""
use_case = ListTaskIssuesUseCase(issue_repo)
issues = use_case.execute(task_id)
return [
TaskIssueResponse(
id=i.id,
task_id=i.task_id,
project_id=i.project_id,
workspace_id=i.workspace_id,
title=i.title,
description=i.description,
resolved=i.resolved,
resolved_at=i.resolved_at,
created_by_user_id=i.created_by_user_id,
created_at=i.created_at,
updated_at=i.updated_at,
)
for i in issues
]
@router.patch("/issues/{issue_id}/resolve", response_model=TaskIssueResponse)
def resolve_issue(
issue_id: str,
issue_repo=Depends(get_issue_repo),
):
"""解决任务问题"""
use_case = ResolveTaskIssueUseCase(issue_repo)
try:
issue = use_case.execute(issue_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return TaskIssueResponse(
id=issue.id,
task_id=issue.task_id,
project_id=issue.project_id,
workspace_id=issue.workspace_id,
title=issue.title,
description=issue.description,
resolved=issue.resolved,
resolved_at=issue.resolved_at,
created_by_user_id=issue.created_by_user_id,
created_at=issue.created_at,
updated_at=issue.updated_at,
)
-86
View File
@@ -1,86 +0,0 @@
from typing import Any
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,
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=[_to_project_response(item) for item in projects])
@router.post("", response_model=ProjectResponse)
def create_project(
request: CreateProjectRequest,
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(
workspace_id=request.workspace_id,
name=request.name,
description=request.description,
)
)
return _to_project_response(project)
-198
View File
@@ -1,198 +0,0 @@
from typing import Any
from uuid import uuid4
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
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
from app.config import get_settings
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 _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(
workspace_id=workspace_id,
project_id=project_id,
library_id=library_id,
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,
ingest_job_id=job.id,
url=file_url,
)
-369
View File
@@ -1,369 +0,0 @@
"""
Workspace API routes wired through the canonical API composition root.
Only the core create/list/detail endpoints are enabled here. Member invitation
and subscription endpoints remain in application use cases but are mounted only
after their persistence repositories are wired and smoke-tested.
"""
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_user_repository,
get_workspace_invitation_repository,
get_workspace_member_repository,
get_workspace_repository,
)
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from packages.application.workspace.accept_invitation_use_case import AcceptInvitationRequest, AcceptInvitationUseCase
from packages.application.workspace.create_workspace_use_case import CreateWorkspaceRequest, CreateWorkspaceUseCase
from packages.application.workspace.invite_member_use_case import InviteMemberRequest, InviteMemberUseCase
from packages.application.workspace.list_members_use_case import ListMembersRequest, ListMembersUseCase
from packages.application.workspace.list_workspaces_use_case import (
GetWorkspaceDetailRequest,
GetWorkspaceDetailUseCase,
ListWorkspacesRequest,
ListWorkspacesUseCase,
)
from packages.application.workspace.remove_member_use_case import RemoveMemberRequest, RemoveMemberUseCase
from packages.application.workspace.update_member_role_use_case import UpdateMemberRoleRequest, UpdateMemberRoleUseCase
from packages.ports.user_repository import UserRepository
from packages.ports.workspace_invitation_repository import WorkspaceInvitationRepository
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
from packages.ports.workspace_repository import WorkspaceRepository
router = APIRouter(prefix="/workspaces", tags=["Workspaces"])
class CreateWorkspaceRequestModel(BaseModel):
name: str
subscription_plan: str = "free"
class WorkspaceResponseModel(BaseModel):
workspace_id: str
name: str
subscription_plan: str
max_projects: int
max_storage_gb: int
class WorkspaceSummaryModel(BaseModel):
workspace_id: str
name: str
subscription_plan: str
max_projects: int
max_storage_gb: int
member_count: int
user_role: str
class WorkspaceListResponseModel(BaseModel):
workspaces: list[WorkspaceSummaryModel]
class WorkspaceDetailResponseModel(BaseModel):
workspace_id: str
name: str
owner_user_id: str
subscription_plan: str
subscription_status: str
max_projects: int
max_storage_gb: int
used_storage_gb: float
member_count: int
user_role: str
class InviteMemberRequestModel(BaseModel):
email: EmailStr
role: str
class InviteMemberResponseModel(BaseModel):
invitation_id: str
invitee_email: str
role: str
expires_at: str
class MemberResponseModel(BaseModel):
member_id: str
user_id: str
username: str
email: str
display_name: str
role: str
invited_by: str | None
joined_at: str
class MemberListResponseModel(BaseModel):
members: list[MemberResponseModel]
class UpdateMemberRoleRequestModel(BaseModel):
role: str
class UpdateMemberRoleResponseModel(BaseModel):
user_id: str
old_role: str
new_role: str
class AcceptInvitationResponseModel(BaseModel):
workspace_id: str
workspace_name: str
role: str
class _NoopInvitationEmailService:
def send_workspace_invitation_email(self, **kwargs):
return False, "Email delivery is disabled for workspace compatibility routes"
@router.post("", response_model=WorkspaceResponseModel, status_code=status.HTTP_201_CREATED)
async def create_workspace(
request: CreateWorkspaceRequestModel,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
user_repository: UserRepository = Depends(get_user_repository),
):
use_case = CreateWorkspaceUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
user_repository=user_repository,
)
response, error = use_case.execute(
CreateWorkspaceRequest(
name=request.name,
owner_user_id=authenticated_user.user.id,
subscription_plan=request.subscription_plan,
)
)
if error or response is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to create workspace")
return WorkspaceResponseModel(
workspace_id=response.workspace_id,
name=response.name,
subscription_plan=response.subscription_plan,
max_projects=response.max_projects,
max_storage_gb=response.max_storage_gb,
)
@router.get("", response_model=WorkspaceListResponseModel)
async def list_workspaces(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
):
use_case = ListWorkspacesUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
)
response, error = use_case.execute(ListWorkspacesRequest(user_id=authenticated_user.user.id))
if error or response is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to list workspaces")
return WorkspaceListResponseModel(
workspaces=[
WorkspaceSummaryModel(
workspace_id=workspace.workspace_id,
name=workspace.name,
subscription_plan=workspace.subscription_plan,
max_projects=workspace.max_projects,
max_storage_gb=workspace.max_storage_gb,
member_count=workspace.member_count,
user_role=workspace.user_role,
)
for workspace in response.workspaces
]
)
@router.post("/invitations/{invitation_token}/accept", response_model=AcceptInvitationResponseModel)
async def accept_invitation(
invitation_token: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
workspace_invitation_repository: WorkspaceInvitationRepository = Depends(get_workspace_invitation_repository),
user_repository: UserRepository = Depends(get_user_repository),
):
response, error = AcceptInvitationUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
workspace_invitation_repository=workspace_invitation_repository,
user_repository=user_repository,
).execute(AcceptInvitationRequest(invitation_token=invitation_token, user_id=authenticated_user.user.id))
if error or response is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to accept invitation")
return AcceptInvitationResponseModel(
workspace_id=response.workspace_id,
workspace_name=response.workspace_name,
role=response.role,
)
@router.get("/{workspace_id}", response_model=WorkspaceDetailResponseModel)
async def get_workspace_detail(
workspace_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
):
use_case = GetWorkspaceDetailUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
)
detail, error = use_case.execute(
GetWorkspaceDetailRequest(workspace_id=workspace_id, user_id=authenticated_user.user.id)
)
if error or detail is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error or "Workspace access denied")
return WorkspaceDetailResponseModel(
workspace_id=detail.workspace_id,
name=detail.name,
owner_user_id=detail.owner_user_id,
subscription_plan=detail.subscription_plan,
subscription_status=detail.subscription_status,
max_projects=detail.max_projects,
max_storage_gb=detail.max_storage_gb,
used_storage_gb=detail.used_storage_gb,
member_count=detail.member_count,
user_role=detail.user_role,
)
@router.post(
"/{workspace_id}/members/invite", response_model=InviteMemberResponseModel, status_code=status.HTTP_201_CREATED
)
async def invite_member(
workspace_id: str,
request: InviteMemberRequestModel,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
workspace_invitation_repository: WorkspaceInvitationRepository = Depends(get_workspace_invitation_repository),
user_repository: UserRepository = Depends(get_user_repository),
):
use_case = InviteMemberUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
workspace_invitation_repository=workspace_invitation_repository,
user_repository=user_repository,
email_service=_NoopInvitationEmailService(),
)
response, error = use_case.execute(
InviteMemberRequest(
workspace_id=workspace_id,
inviter_user_id=authenticated_user.user.id,
invitee_email=request.email,
role=request.role,
)
)
if error or response is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to invite member")
return InviteMemberResponseModel(
invitation_id=response.invitation_id,
invitee_email=response.invitee_email,
role=response.role,
expires_at=response.expires_at.isoformat(),
)
@router.get("/{workspace_id}/members", response_model=MemberListResponseModel)
async def list_members(
workspace_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
user_repository: UserRepository = Depends(get_user_repository),
):
use_case = ListMembersUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
user_repository=user_repository,
)
response, error = use_case.execute(
ListMembersRequest(workspace_id=workspace_id, requester_user_id=authenticated_user.user.id)
)
if error or response is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error or "Failed to list members")
return MemberListResponseModel(
members=[
MemberResponseModel(
member_id=member.member_id,
user_id=member.user_id,
username=member.username,
email=member.email,
display_name=member.display_name,
role=member.role,
invited_by=member.invited_by,
joined_at=member.joined_at.isoformat(),
)
for member in response.members
]
)
@router.patch("/{workspace_id}/members/{user_id}/role", response_model=UpdateMemberRoleResponseModel)
async def update_member_role(
workspace_id: str,
user_id: str,
request: UpdateMemberRoleRequestModel,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
):
use_case = UpdateMemberRoleUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
)
response, error = use_case.execute(
UpdateMemberRoleRequest(
workspace_id=workspace_id,
requester_user_id=authenticated_user.user.id,
target_user_id=user_id,
new_role=request.role,
)
)
if error or response is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to update member role")
return UpdateMemberRoleResponseModel(
user_id=response.user_id,
old_role=response.old_role,
new_role=response.new_role,
)
@router.delete("/{workspace_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_member(
workspace_id: str,
user_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
):
success, error = RemoveMemberUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
).execute(
RemoveMemberRequest(
workspace_id=workspace_id,
requester_user_id=authenticated_user.user.id,
target_user_id=user_id,
)
)
if not success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to remove member")
-67
View File
@@ -1,67 +0,0 @@
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"},
)
-114
View File
@@ -1,114 +0,0 @@
import os
from typing import Optional
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
APP_NAME: str = "xiaoxia-saas"
APP_VERSION: str = "0.1.0"
ENVIRONMENT: str = "development"
DEBUG: bool = True
# 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"
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
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"
# 阿里云 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"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
@property
def CORS_ORIGINS(self) -> list[str]:
return [origin.strip() for origin in self.CORS_ORIGINS_RAW.split(",") if origin.strip()]
@property
def database_url(self) -> str:
return self.DATABASE_URL
@property
def redis_url(self) -> str:
return self.REDIS_URL
@property
def celery_broker_url(self) -> str:
return self.CELERY_BROKER_URL
@property
def celery_result_backend(self) -> str:
return self.CELERY_RESULT_BACKEND
@property
def oss_endpoint(self) -> str:
return self.OSS_ENDPOINT
@property
def oss_access_key_id(self) -> str:
return self.OSS_ACCESS_KEY_ID
@property
def oss_access_key_secret(self) -> str:
return self.OSS_ACCESS_KEY_SECRET
@property
def oss_bucket_name(self) -> str:
return self.OSS_BUCKET_NAME
_settings: Optional[Settings] = None
def get_settings() -> Settings:
global _settings
if _settings is None:
env = os.getenv("APP_ENV", "development")
env_file = f".env.{env}" if env != "development" else ".env"
if os.path.exists(env_file):
_settings = Settings(_env_file=env_file)
else:
_settings = Settings()
return _settings
settings = get_settings()
-1
View File
@@ -1 +0,0 @@
"""Core configuration package."""
-7
View File
@@ -1,7 +0,0 @@
from app.config import get_settings
from celery import Celery
settings = get_settings()
celery_app = Celery("xiaoxia-saas-api")
celery_app.conf.broker_url = settings.CELERY_BROKER_URL
celery_app.conf.result_backend = settings.CELERY_RESULT_BACKEND
-19
View File
@@ -1,19 +0,0 @@
"""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
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
__all__ = ["AppSettings", "get_settings", "reload_settings", "settings"]
-34
View File
@@ -1,34 +0,0 @@
import os
from typing import Optional
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseSettings(BaseSettings):
database_url: str = "postgresql+psycopg://postgres:postgres@postgres:5432/xiaoxia_saas"
pool_size: int = 20
max_overflow: int = 40
pool_timeout: int = 30
pool_recycle: int = 3600
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
_settings: Optional[DatabaseSettings] = None
def get_database_settings() -> DatabaseSettings:
global _settings
if _settings is None:
env = os.getenv("APP_ENV", "development")
env_file = f".env.{env}" if env != "development" else ".env"
if os.path.exists(env_file):
_settings = DatabaseSettings(_env_file=env_file)
else:
_settings = DatabaseSettings()
return _settings
-232
View File
@@ -1,232 +0,0 @@
"""阿里云 OSS 存储服务"""
import base64
import datetime as dt
import hashlib
import hmac
import json
import logging
import os
from urllib.parse import urlparse
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 OSSStorageService:
"""阿里云 OSS 存储服务"""
def __init__(self):
settings = get_settings()
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
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_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:
# 如果是字符串路径,从本地文件上传
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:
"""获取文件公网 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.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)
# 向后兼容的服务类名
MinIOService = OSSStorageService
_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()
-31
View File
@@ -1,31 +0,0 @@
from collections.abc import Generator
from app.config import settings
from sqlalchemy.orm import Session
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(
settings.DATABASE_URL,
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_timeout=settings.DATABASE_POOL_TIMEOUT,
pool_recycle=settings.DATABASE_POOL_RECYCLE,
)
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]:
db = SessionLocal()
try:
yield db
finally:
db.close()
-134
View File
@@ -1,134 +0,0 @@
import redis
from app.config import settings
from fastapi import Depends
from sqlalchemy.orm import Session
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.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.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)
def get_db_session():
session: Session = _SessionLocal()
try:
yield session
finally:
session.close()
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:
return SQLAlchemyAssetLibraryRepository(session)
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:
return SQLAlchemyClassificationJobRepository(session)
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_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,
)
-76
View File
@@ -1,76 +0,0 @@
"""
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 HTTPAuthorizationCredentials, HTTPBearer
from packages.domain.entities import User
from packages.domain.permissions import PermissionChecker
from packages.ports.user_repository import UserRepository
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
optional_bearer_scheme = HTTPBearer(auto_error=False)
async def get_current_user(
authenticated_user: AuthenticatedUser = Depends(get_authenticated_user),
) -> User:
return authenticated_user.user
async def get_current_user_optional(
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer_scheme),
user_repository: UserRepository = Depends(get_user_repository),
) -> User | None:
if credentials is None:
return None
try:
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),
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),
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),
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"
)
return workspace_id
-139
View File
@@ -1,139 +0,0 @@
"""
全局异常处理和错误响应
"""
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,
status_code: int = status.HTTP_400_BAD_REQUEST,
error_code: str = "API_ERROR",
):
self.message = message
self.status_code = status_code
self.error_code = error_code
super().__init__(message)
class AuthenticationError(APIException):
"""认证错误"""
def __init__(self, message: str = "Authentication failed"):
super().__init__(
message=message,
status_code=status.HTTP_401_UNAUTHORIZED,
error_code="AUTH_ERROR",
)
class PermissionDeniedError(APIException):
"""权限拒绝"""
def __init__(self, message: str = "Permission denied"):
super().__init__(
message=message,
status_code=status.HTTP_403_FORBIDDEN,
error_code="PERMISSION_DENIED",
)
class ResourceNotFoundError(APIException):
"""资源不存在"""
def __init__(self, resource: str = "Resource"):
super().__init__(
message=f"{resource} not found",
status_code=status.HTTP_404_NOT_FOUND,
error_code="NOT_FOUND",
)
class ValidationError(APIException):
"""验证错误"""
def __init__(self, message: str):
super().__init__(
message=message,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
error_code="VALIDATION_ERROR",
)
async def api_exception_handler(request: Request, exc: APIException):
"""API 异常处理"""
return JSONResponse(
status_code=exc.status_code,
content={
"error": {
"code": exc.error_code,
"message": exc.message,
}
},
)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
"""HTTP 异常处理"""
return JSONResponse(
status_code=exc.status_code,
content={
"error": {
"code": f"HTTP_{exc.status_code}",
"message": exc.detail,
}
},
)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""请求验证异常处理"""
errors = []
for error in exc.errors():
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={
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": errors,
}
},
)
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,
content={
"error": {
"code": "INTERNAL_ERROR",
"message": "An internal error occurred",
# "detail": str(exc), # 仅在开发环境启用
}
},
)
-89
View File
@@ -1,89 +0,0 @@
"""
请求日志中间件
"""
import logging
import time
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
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"
)
# 添加响应头
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
]
# 检查速率限制
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={
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": f"Too many requests. Limit: {self.max_requests} per {self.window_seconds}s",
}
},
)
# 记录请求
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]))
return response
-104
View File
@@ -1,104 +0,0 @@
"""
性能监控中间件
"""
import logging
import time
from typing import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
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(
f"Slow request detected: {request.method} {request.url.path} "
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,
)
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.total_time += duration
# 记录慢查询(超过 100ms)
if duration > 0.1:
logger.warning(f"Slow query detected: {query[:100]}... " f"took {duration:.3f}s with params {params}")
def get_stats(self):
"""获取统计信息"""
return {
"total_queries": len(self.queries),
"total_time": self.total_time,
"avg_time": self.total_time / len(self.queries) if self.queries else 0,
"slow_queries": len([q for q in self.queries if q["duration"] > 0.1]),
}
-94
View File
@@ -1,94 +0,0 @@
"""
API 版本管理中间件
"""
from datetime import datetime
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
class APIVersionMiddleware(BaseHTTPMiddleware):
"""API 版本管理中间件"""
# 版本配置
VERSIONS = {
"v1": {
"status": "stable",
"deprecated": False,
"sunset_date": None,
"release_date": "2026-06-17",
},
"v2": {
"status": "development",
"deprecated": False,
"sunset_date": None,
"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}"
return response
def _extract_version(self, path: str) -> str:
"""从路径中提取版本号"""
parts = path.split("/")
for part in parts:
if part.startswith("v") and part[1:].isdigit():
return part
return None
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={
"error": {
"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}",
}
},
)
return await call_next(request)
def _extract_version(self, path: str) -> str:
"""从路径中提取版本号"""
parts = path.split("/")
for part in parts:
if part.startswith("v") and part[1:].isdigit():
return part
return None
-28
View File
@@ -1,28 +0,0 @@
"""Schema package."""
from .asset import AssetResponse, CreateAssetRequest, ListAssetsResponse
from .asset_library import (
AssetLibraryResponse,
CreateAssetLibraryRequest,
ListAssetLibrariesResponse,
)
from .health import HealthResponse
from .ingest_job import IngestJobResponse, SubmitIngestJobRequest
from .project import CreateProjectRequest, ListProjectsResponse, ProjectResponse
from .upload import UploadAssetResponse
__all__ = [
"AssetResponse",
"AssetLibraryResponse",
"CreateAssetLibraryRequest",
"CreateAssetRequest",
"CreateProjectRequest",
"HealthResponse",
"IngestJobResponse",
"ListAssetLibrariesResponse",
"ListAssetsResponse",
"ListProjectsResponse",
"ProjectResponse",
"SubmitIngestJobRequest",
"UploadAssetResponse",
]
-48
View File
@@ -1,48 +0,0 @@
from pydantic import BaseModel, Field
class CreateAssetRequest(BaseModel):
workspace_id: str = Field(..., min_length=1)
project_id: str = Field(..., min_length=1)
library_id: str = Field(..., min_length=1)
name: str = Field(..., min_length=1, max_length=100)
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 AssetResponse(BaseModel):
id: str
workspace_id: str
project_id: str
library_id: str
name: str
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):
items: list[AssetResponse]
-22
View File
@@ -1,22 +0,0 @@
from pydantic import BaseModel, Field
class CreateAssetLibraryRequest(BaseModel):
workspace_id: str = Field(..., min_length=1)
project_id: str = Field(..., min_length=1)
name: str = Field(..., min_length=1, max_length=100)
kind: str = Field(..., pattern="^(video|voice|image)$")
class AssetLibraryResponse(BaseModel):
id: str
workspace_id: str
project_id: str
name: str
kind: str
asset_count: int
total_size: int
class ListAssetLibrariesResponse(BaseModel):
items: list[AssetLibraryResponse]
@@ -1,18 +0,0 @@
from pydantic import BaseModel, Field
class SubmitClassificationJobRequest(BaseModel):
workspace_id: str = Field(..., min_length=1)
project_id: str = Field(..., min_length=1)
asset_id: str = Field(..., min_length=1)
class ClassificationJobResponse(BaseModel):
id: str
workspace_id: str
project_id: str
asset_id: str
status: str
classification: str
confidence: float
error_message: str
-25
View File
@@ -1,25 +0,0 @@
from pydantic import BaseModel
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
class GeneratedVideoDownloadUrlResponse(BaseModel):
video_id: str
download_url: str
class ListGeneratedVideosResponse(BaseModel):
items: list[GeneratedVideoResponse]
-23
View File
@@ -1,23 +0,0 @@
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 = ""
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
status: str
progress: float
result_count: int
error_message: str
-6
View File
@@ -1,6 +0,0 @@
from pydantic import BaseModel
class HealthResponse(BaseModel):
ok: bool
service: str
-19
View File
@@ -1,19 +0,0 @@
from pydantic import BaseModel, Field
class SubmitIngestJobRequest(BaseModel):
workspace_id: str = Field(..., min_length=1)
project_id: str = Field(..., min_length=1)
library_id: str = Field(..., min_length=1)
storage_key: str = Field(..., min_length=1, max_length=255)
class IngestJobResponse(BaseModel):
id: str
workspace_id: str
project_id: str
library_id: str
storage_key: str
status: str
error_message: str
result_asset_id: str
-18
View File
@@ -1,18 +0,0 @@
from pydantic import BaseModel, Field
class CreateProjectRequest(BaseModel):
workspace_id: str = Field(..., min_length=1)
name: str = Field(..., min_length=1, max_length=100)
description: str = Field(default="", max_length=500)
class ProjectResponse(BaseModel):
id: str
workspace_id: str
name: str
description: str
class ListProjectsResponse(BaseModel):
items: list[ProjectResponse]
-37
View File
@@ -1,37 +0,0 @@
from pydantic import BaseModel, Field
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
-67
View File
@@ -1,67 +0,0 @@
import os
from app.api.router import api_router, health_router
from app.config import settings
from app.middleware.exceptions import (
APIException,
api_exception_handler,
general_exception_handler,
http_exception_handler,
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",
description="自动化剪辑 SaaS 平台 API",
version=settings.APP_VERSION,
docs_url="/docs",
redoc_url="/redoc",
)
app.add_exception_handler(APIException, api_exception_handler)
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(Exception, general_exception_handler)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
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",
)
@app.get("/")
async def root():
return {
"service": settings.APP_NAME,
"status": "running",
"version": settings.APP_VERSION,
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host=settings.API_HOST, port=settings.API_PORT)
-8
View File
@@ -1,8 +0,0 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
def health():
return {"ok": True, "service": "api"}
-23
View File
@@ -1,23 +0,0 @@
/**
* ESLint 配置
*/
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
}
-3
View File
@@ -1,3 +0,0 @@
# Workspace settings
This file keeps editor/workspace-specific settings if needed later.
-118
View File
@@ -1,118 +0,0 @@
# 小虾 SaaS - 前端应用
基于 Vite + React 18 + TypeScript + Ant Design 的现代化 SaaS 前端应用。
## 快速开始
```bash
# 安装依赖
npm install
# 启动开发服务器
npm run dev
# 构建生产版本
npm run build
# 预览生产版本
npm run preview
```
## 技术栈
- **框架:** React 18.3.1
- **语言:** TypeScript 5.5.3
- **构建工具:** Vite 5.3.1
- **UI 组件库:** Ant Design 5.18.0
- **路由:** React Router 6.24.0
- **状态管理:** Zustand 4.5.2
- **数据获取:** React Query 5.45.0
- **HTTP 客户端:** Axios 1.7.2
- **表单:** React Hook Form 7.52.0 + Zod 3.23.8
## 项目结构
```
src/
├── api/ # API 服务层
├── components/ # React 组件
│ ├── common/ # 通用组件
│ ├── layout/ # 布局组件
│ └── business/ # 业务组件
├── pages/ # 页面组件
│ ├── auth/ # 认证页面
│ ├── workspace/ # 工作空间
│ ├── subscription/# 订阅管理
│ ├── admin/ # Admin 后台
│ └── profile/ # 个人中心
├── hooks/ # 自定义 Hooks
├── store/ # 状态管理
├── router/ # 路由配置
├── types/ # TypeScript 类型
├── utils/ # 工具函数
└── styles/ # 全局样式
```
## 功能特性
- ✅ 用户认证(登录/注册/密码重置)
- ✅ 工作空间管理
- ✅ 成员管理和权限控制
- ✅ 订阅计划和升级
- ✅ 配额使用监控
- ✅ Admin 管理后台
- ✅ 个人设置和安全
- ✅ 响应式设计
## 环境变量
创建 `.env` 文件:
```env
VITE_API_URL=http://localhost:8000
```
## 开发指南
### 添加新页面
1. 在 `src/pages/` 下创建页面组件
2. 在 `src/router/index.tsx` 中添加路由
3. 确保导出 `Component` 用于懒加载
### 添加新 API
1. 在 `src/api/` 下创建服务模块
2. 定义 TypeScript 接口
3. 使用 `apiClient` 发起请求
### 状态管理
使用 Zustand 创建 Store:
```typescript
import { create } from 'zustand';
interface MyStore {
data: any;
setData: (data: any) => void;
}
export const useMyStore = create<MyStore>((set) => ({
data: null,
setData: (data) => set({ data }),
}));
```
## 部署
```bash
# 构建
npm run build
# 产物在 dist/ 目录
```
## License
MIT
-147
View File
@@ -1,147 +0,0 @@
'use client';
import { useState } from 'react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
interface CreateIssueFormProps {
taskId: string;
projectId: string;
workspaceId: string;
onSuccess: () => void;
onCancel: () => void;
}
export default function CreateIssueForm({ taskId, projectId, workspaceId, onSuccess, onCancel }: CreateIssueFormProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState({
title: '',
description: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const res = await fetch(`${API_BASE}/api/v1/project-management/issues`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
task_id: taskId,
project_id: projectId,
workspace_id: workspaceId,
...formData,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || '创建失败');
}
onSuccess();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} style={{
background: 'var(--bg-white)',
padding: '20px',
borderRadius: '8px',
border: '1px solid var(--border)',
}}>
{error && (
<div style={{
padding: '12px',
background: '#FFECE8',
border: '1px solid var(--error)',
borderRadius: '4px',
color: 'var(--error)',
marginBottom: '16px',
}}>
{error}
</div>
)}
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500' }}>
问题标题 <span style={{ color: 'var(--error)' }}>*</span>
</label>
<input
type="text"
required
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: '4px',
fontSize: '14px',
}}
placeholder="简要描述问题"
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500' }}>
详细描述
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: '4px',
fontSize: '14px',
resize: 'vertical',
}}
placeholder="详细说明问题情况"
/>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button
type="submit"
disabled={loading}
style={{
flex: 1,
padding: '8px',
borderRadius: '4px',
border: 'none',
background: loading ? '#ccc' : 'var(--primary)',
color: 'white',
fontWeight: 'bold',
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
{loading ? '创建中...' : '创建问题'}
</button>
<button
type="button"
onClick={onCancel}
style={{
flex: 1,
padding: '8px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'white',
cursor: 'pointer',
}}
>
取消
</button>
</div>
</form>
);
}
-200
View File
@@ -1,200 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
interface CreateTaskFormProps {
projectId: string;
workspaceId: string;
onSuccess?: () => void;
onCancel?: () => void;
}
export default function CreateTaskForm({ projectId, workspaceId, onSuccess, onCancel }: CreateTaskFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState({
name: '',
description: '',
priority: 'medium',
assignee_user_id: '',
parent_task_id: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const res = await fetch(`${API_BASE}/api/v1/project-management/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project_id: projectId,
workspace_id: workspaceId,
...formData,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || '创建失败');
}
if (onSuccess) {
onSuccess();
} else {
router.push('/projects');
}
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} style={{
background: 'var(--bg-white)',
padding: '24px',
borderRadius: '8px',
maxWidth: '600px',
margin: '0 auto',
}}>
<h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: 'bold' }}>新增任务</h2>
{error && (
<div style={{
padding: '12px',
background: '#FFECE8',
border: '1px solid var(--error)',
borderRadius: '4px',
color: 'var(--error)',
marginBottom: '20px',
}}>
{error}
</div>
)}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
任务名称 <span style={{ color: 'var(--error)' }}>*</span>
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: '4px',
fontSize: '14px',
}}
placeholder="输入任务名称"
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
任务描述
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: '4px',
fontSize: '14px',
resize: 'vertical',
}}
placeholder="详细描述任务内容"
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
优先级
</label>
<select
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: '4px',
fontSize: '14px',
}}
>
<option value="low">低</option>
<option value="medium">中</option>
<option value="high">高</option>
<option value="urgent">紧急</option>
</select>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
负责人 ID(可选)
</label>
<input
type="text"
value={formData.assignee_user_id}
onChange={(e) => setFormData({ ...formData, assignee_user_id: e.target.value })}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: '4px',
fontSize: '14px',
}}
placeholder="输入负责人 ID"
/>
</div>
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
<button
type="submit"
disabled={loading}
style={{
flex: 1,
padding: '10px',
borderRadius: '4px',
border: 'none',
background: loading ? '#ccc' : 'var(--primary)',
color: 'white',
fontWeight: 'bold',
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
{loading ? '创建中...' : '创建任务'}
</button>
{onCancel && (
<button
type="button"
onClick={onCancel}
style={{
flex: 1,
padding: '10px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'white',
cursor: 'pointer',
}}
>
取消
</button>
)}
</div>
</form>
);
}
-169
View File
@@ -1,169 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
interface EditTaskFormProps {
taskId: string;
initialData: {
name: string;
description: string;
priority: string;
assignee_user_id: string;
};
onSuccess?: () => void;
onCancel?: () => void;
}
export default function EditTaskForm({ taskId, initialData, onSuccess, onCancel }: EditTaskFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState(initialData);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const res = await fetch(`${API_BASE}/api/v1/project-management/tasks/${taskId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || '保存失败');
}
if (onSuccess) onSuccess();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} style={{
background: 'var(--bg-white)',
padding: '24px',
borderRadius: '8px',
border: '1px solid var(--border)',
}}>
<h3 style={{ marginBottom: '20px', fontSize: '18px', fontWeight: 'bold' }}>编辑任务</h3>
{error && (
<div style={{
padding: '12px',
background: '#FFECE8',
border: '1px solid var(--error)',
borderRadius: '4px',
color: 'var(--error)',
marginBottom: '20px',
}}>
{error}
</div>
)}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
任务名称 <span style={{ color: 'var(--error)' }}>*</span>
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: '4px',
fontSize: '14px',
}}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
任务描述
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: '4px',
fontSize: '14px',
resize: 'vertical',
}}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
优先级
</label>
<select
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: '4px',
fontSize: '14px',
}}
>
<option value="low">低</option>
<option value="medium">中</option>
<option value="high">高</option>
<option value="urgent">紧急</option>
</select>
</div>
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
<button
type="submit"
disabled={loading}
style={{
flex: 1,
padding: '10px',
borderRadius: '4px',
border: 'none',
background: loading ? '#ccc' : 'var(--primary)',
color: 'white',
fontWeight: 'bold',
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
{loading ? '保存中...' : '保存修改'}
</button>
{onCancel && (
<button
type="button"
onClick={onCancel}
style={{
flex: 1,
padding: '10px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'white',
cursor: 'pointer',
}}
>
取消
</button>
)}
</div>
</form>
);
}
-26
View File
@@ -1,26 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Microsoft YaHei", sans-serif;
background: #F5F7FA;
color: #1D2129;
height: 100vh;
overflow: hidden;
}
:root {
--primary: #165DFF;
--primary-hover: #0e48d1;
--border: #E5E6EB;
--bg-white: #fff;
--bg-gray: #F5F7FA;
--text-primary: #1D2129;
--text-secondary: #6E7681;
--success: #00B42A;
--warning: #FF7D00;
--error: #F53F3F;
}
-19
View File
@@ -1,19 +0,0 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "小虾 SaaS - 项目推进器",
description: "AI 视频自动化剪辑系统 - 项目管理",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}
-234
View File
@@ -1,234 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
interface Milestone {
id: string;
name: string;
description: string;
target_date: string | null;
completed: boolean;
completed_at: string | null;
created_at: string;
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
export default function MilestonesPage() {
const [milestones, setMilestones] = useState<Milestone[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
const [formData, setFormData] = useState({ name: '', description: '' });
const projectId = 'demo_project_1';
const workspaceId = 'demo_workspace_1';
useEffect(() => {
fetchMilestones();
}, []);
const fetchMilestones = async () => {
setLoading(true);
try {
const res = await fetch(`${API_BASE}/api/v1/project-management/milestones?project_id=${projectId}`);
if (!res.ok) throw new Error('获取里程碑列表失败');
const data = await res.json();
setMilestones(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleCreateMilestone = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await fetch(`${API_BASE}/api/v1/project-management/milestones`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project_id: projectId,
workspace_id: workspaceId,
...formData,
}),
});
if (!res.ok) throw new Error('创建失败');
setFormData({ name: '', description: '' });
setShowCreateForm(false);
fetchMilestones();
} catch (err: any) {
alert(err.message);
}
};
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<p>加载中...</p>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
{/* Header */}
<header style={{
height: '60px',
background: 'var(--bg-white)',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
padding: '0 20px',
justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
<Link href="/" style={{ fontSize: '18px', fontWeight: 'bold', color: 'var(--primary)', textDecoration: 'none' }}>
📁 项目推进器
</Link>
<span style={{ color: 'var(--text-secondary)' }}>里程碑管理</span>
</div>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
style={{
padding: '6px 12px',
borderRadius: '4px',
border: 'none',
background: 'var(--primary)',
color: 'white',
cursor: 'pointer',
fontWeight: 'bold',
}}
>
{showCreateForm ? '取消' : '+ 新增里程碑'}
</button>
</header>
{/* Main */}
<main style={{ flex: 1, padding: '20px', overflow: 'auto' }}>
{error && (
<div style={{
padding: '12px',
background: '#FFECE8',
border: '1px solid var(--error)',
borderRadius: '4px',
color: 'var(--error)',
marginBottom: '20px',
}}>
{error}
</div>
)}
{showCreateForm && (
<div style={{ background: 'var(--bg-white)', padding: '24px', borderRadius: '8px', marginBottom: '20px' }}>
<h3 style={{ marginBottom: '16px', fontSize: '18px', fontWeight: 'bold' }}>新增里程碑</h3>
<form onSubmit={handleCreateMilestone}>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500' }}>里程碑名称 *</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: '4px',
}}
placeholder="例如:V1.0 发布"
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500' }}>描述</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: '4px',
resize: 'vertical',
}}
placeholder="详细说明里程碑内容"
/>
</div>
<button
type="submit"
style={{
padding: '8px 16px',
borderRadius: '4px',
border: 'none',
background: 'var(--primary)',
color: 'white',
fontWeight: 'bold',
cursor: 'pointer',
}}
>
创建里程碑
</button>
</form>
</div>
)}
{milestones.length === 0 ? (
<div style={{
background: 'var(--bg-white)',
borderRadius: '8px',
padding: '40px',
textAlign: 'center',
color: 'var(--text-secondary)',
}}>
<p>暂无里程碑</p>
<p style={{ fontSize: '14px', marginTop: '8px' }}>点击右上角"+ 新增里程碑"创建第一个里程碑</p>
</div>
) : (
<div style={{ display: 'grid', gap: '16px' }}>
{milestones.map((milestone) => (
<div
key={milestone.id}
style={{
background: 'var(--bg-white)',
padding: '20px',
borderRadius: '8px',
border: `2px solid ${milestone.completed ? 'var(--success)' : 'var(--border)'}`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<span style={{ fontSize: '24px' }}>{milestone.completed ? '🎉' : '🎯'}</span>
<h3 style={{ fontSize: '18px', fontWeight: 'bold', flex: 1 }}>{milestone.name}</h3>
<span style={{
padding: '4px 12px',
borderRadius: '4px',
fontSize: '12px',
background: milestone.completed ? 'var(--success)' : '#E5E6EB',
color: milestone.completed ? 'white' : 'var(--text-secondary)',
}}>
{milestone.completed ? '已完成' : '进行中'}
</span>
</div>
{milestone.description && (
<p style={{ color: 'var(--text-secondary)', lineHeight: '1.6', marginBottom: '12px' }}>
{milestone.description}
</p>
)}
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
创建时间:{new Date(milestone.created_at).toLocaleDateString('zh-CN')}
{milestone.completed_at && (
<span style={{ marginLeft: '12px' }}>
完成时间:{new Date(milestone.completed_at).toLocaleDateString('zh-CN')}
</span>
)}
</div>
</div>
))}
</div>
)}
</main>
</div>
);
}
-62
View File
@@ -1,62 +0,0 @@
export default function HomePage() {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
alignItems: 'center',
justifyContent: 'center',
gap: '20px'
}}>
<h1 style={{ fontSize: '32px', fontWeight: 'bold', color: 'var(--primary)' }}>
📁 小虾 SaaS 项目推进器
</h1>
<p style={{ color: 'var(--text-secondary)' }}>
完整的项目管理与任务跟踪系统
</p>
<div style={{ display: 'flex', gap: '12px' }}>
<a
href="/projects"
style={{
padding: '10px 20px',
background: 'var(--primary)',
color: 'white',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: 'bold'
}}
>
进入项目列表
</a>
<a
href="/milestones"
style={{
padding: '10px 20px',
background: 'white',
color: 'var(--primary)',
border: '1px solid var(--primary)',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: 'bold'
}}
>
里程碑管理
</a>
<a
href="/api/docs"
target="_blank"
style={{
padding: '10px 20px',
background: 'white',
color: 'var(--primary)',
border: '1px solid var(--primary)',
borderRadius: '6px',
textDecoration: 'none'
}}
>
查看 API 文档
</a>
</div>
</div>
);
}
-261
View File
@@ -1,261 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import CreateTaskForm from '../components/CreateTaskForm';
interface Task {
id: string;
name: string;
status: string;
priority: string;
progress: number;
assignee_user_id: string;
created_at: string;
updated_at: string;
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
export default function ProjectsPage() {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
// 模拟项目ID,生产环境应该从路由或上下文获取
const projectId = 'demo_project_1';
const workspaceId = 'demo_workspace_1';
useEffect(() => {
fetchTasks();
}, []);
const fetchTasks = async () => {
setLoading(true);
try {
const res = await fetch(`${API_BASE}/api/v1/project-management/tasks?project_id=${projectId}`);
if (!res.ok) throw new Error('获取任务列表失败');
const data = await res.json();
setTasks(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
pending: '#86909C',
in_progress: '#165DFF',
completed: '#00B42A',
blocked: '#F53F3F',
cancelled: '#6E7681',
};
return colors[status] || '#6E7681';
};
const getStatusText = (status: string) => {
const texts: Record<string, string> = {
pending: '待开始',
in_progress: '进行中',
completed: '已完成',
blocked: '阻塞',
cancelled: '已取消',
};
return texts[status] || status;
};
const getPriorityText = (priority: string) => {
const texts: Record<string, string> = {
low: '低',
medium: '中',
high: '高',
urgent: '紧急',
};
return texts[priority] || priority;
};
if (loading && !showCreateForm) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<p style={{ color: 'var(--text-secondary)' }}>加载中...</p>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
{/* Header */}
<header style={{
height: '60px',
background: 'var(--bg-white)',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
padding: '0 20px',
justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
<Link href="/" style={{ fontSize: '18px', fontWeight: 'bold', color: 'var(--primary)', textDecoration: 'none' }}>
📁 项目推进器
</Link>
<span style={{ color: 'var(--text-secondary)' }}>Demo 项目</span>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button
onClick={fetchTasks}
style={{
padding: '6px 12px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--bg-white)',
cursor: 'pointer',
}}
>
刷新
</button>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
style={{
padding: '6px 12px',
borderRadius: '4px',
border: 'none',
background: 'var(--primary)',
color: 'white',
cursor: 'pointer',
fontWeight: 'bold',
}}
>
{showCreateForm ? '取消' : '+ 新增任务'}
</button>
</div>
</header>
{/* Main Content */}
<main style={{ flex: 1, padding: '20px', overflow: 'auto' }}>
{error && (
<div style={{
padding: '12px',
background: '#FFECE8',
border: '1px solid #F53F3F',
borderRadius: '4px',
color: '#F53F3F',
marginBottom: '20px',
}}>
{error}
</div>
)}
{showCreateForm ? (
<CreateTaskForm
projectId={projectId}
workspaceId={workspaceId}
onSuccess={() => {
setShowCreateForm(false);
fetchTasks();
}}
onCancel={() => setShowCreateForm(false)}
/>
) : tasks.length === 0 ? (
<div style={{
background: 'var(--bg-white)',
borderRadius: '8px',
padding: '40px',
textAlign: 'center',
color: 'var(--text-secondary)',
}}>
<p>暂无任务</p>
<p style={{ fontSize: '14px', marginTop: '8px' }}>点击右上角"+ 新增任务"创建第一个任务</p>
</div>
) : (
<div style={{
background: 'var(--bg-white)',
borderRadius: '8px',
padding: '20px',
boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '12px', textAlign: 'left', color: 'var(--text-secondary)', fontWeight: 'normal' }}>任务名称</th>
<th style={{ padding: '12px', textAlign: 'left', color: 'var(--text-secondary)', fontWeight: 'normal' }}>状态</th>
<th style={{ padding: '12px', textAlign: 'left', color: 'var(--text-secondary)', fontWeight: 'normal' }}>优先级</th>
<th style={{ padding: '12px', textAlign: 'left', color: 'var(--text-secondary)', fontWeight: 'normal' }}>进度</th>
<th style={{ padding: '12px', textAlign: 'left', color: 'var(--text-secondary)', fontWeight: 'normal' }}>创建时间</th>
</tr>
</thead>
<tbody>
{tasks.map((task) => (
<tr key={task.id} style={{ borderBottom: '1px solid #F7F8FA' }}>
<td style={{ padding: '12px', fontWeight: '500' }}>
<Link href={`/tasks/${task.id}`} style={{ color: 'var(--primary)', textDecoration: 'none' }}>
{task.name}
</Link>
</td>
<td style={{ padding: '12px' }}>
<span style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
color: 'white',
background: getStatusColor(task.status),
}}>
{getStatusText(task.status)}
</span>
</td>
<td style={{ padding: '12px', color: 'var(--text-secondary)' }}>
{getPriorityText(task.priority)}
</td>
<td style={{ padding: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{
flex: 1,
height: '6px',
background: '#E5E6EB',
borderRadius: '3px',
overflow: 'hidden',
}}>
<div style={{
width: `${task.progress}%`,
height: '100%',
background: 'var(--primary)',
transition: 'width 0.3s',
}} />
</div>
<span style={{ fontSize: '12px', color: 'var(--text-secondary)', minWidth: '40px' }}>
{task.progress}%
</span>
</div>
</td>
<td style={{ padding: '12px', fontSize: '12px', color: 'var(--text-secondary)' }}>
{new Date(task.created_at).toLocaleDateString('zh-CN')}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</main>
{/* Footer */}
<footer style={{
height: '30px',
lineHeight: '30px',
background: 'var(--bg-white)',
borderTop: '1px solid var(--border)',
padding: '0 20px',
display: 'flex',
justifyContent: 'space-between',
fontSize: '12px',
color: 'var(--text-secondary)',
}}>
<div>当前项目:Demo 项目</div>
<div>总计任务:{tasks.length}</div>
</footer>
</div>
);
}
-355
View File
@@ -1,355 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import CreateIssueForm from '../../components/CreateIssueForm';
import EditTaskForm from '../../components/EditTaskForm';
interface Task {
id: string;
name: string;
description: string;
status: string;
priority: string;
progress: number;
assignee_user_id: string;
parent_task_id: string;
project_id: string;
workspace_id: string;
planned_start_date: string | null;
planned_end_date: string | null;
actual_start_date: string | null;
actual_end_date: string | null;
tags: string[];
created_at: string;
updated_at: string;
}
interface TaskIssue {
id: string;
title: string;
description: string;
resolved: boolean;
resolved_at: string | null;
created_at: string;
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
export default function TaskDetailPage() {
const params = useParams();
const router = useRouter();
const taskId = params.id as string;
const [task, setTask] = useState<Task | null>(null);
const [issues, setIssues] = useState<TaskIssue[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [updating, setUpdating] = useState(false);
const [showIssueForm, setShowIssueForm] = useState(false);
const [showEditForm, setShowEditForm] = useState(false);
useEffect(() => {
fetchTaskDetail();
fetchTaskIssues();
}, [taskId]);
const fetchTaskDetail = async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/project-management/tasks/${taskId}`);
if (!res.ok) {
if (res.status === 404) {
throw new Error('任务不存在');
}
throw new Error('获取任务详情失败');
}
const data = await res.json();
setTask(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const fetchTaskIssues = async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/project-management/issues?task_id=${taskId}`);
if (res.ok) {
const data = await res.json();
setIssues(data);
}
} catch (err) {
console.error('获取问题列表失败:', err);
}
};
const resolveIssue = async (issueId: string) => {
setUpdating(true);
try {
const res = await fetch(`${API_BASE}/api/v1/project-management/issues/${issueId}/resolve`, {
method: 'PATCH',
});
if (!res.ok) throw new Error('解决问题失败');
await fetchTaskIssues();
} catch (err: any) {
alert(err.message);
} finally {
setUpdating(false);
}
};
const updateStatus = async (newStatus: string) => {
setUpdating(true);
try {
const res = await fetch(`${API_BASE}/api/v1/project-management/tasks/${taskId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
});
if (!res.ok) throw new Error('更新状态失败');
await fetchTaskDetail();
} catch (err: any) {
alert(err.message);
} finally {
setUpdating(false);
}
};
const updateProgress = async (newProgress: number) => {
setUpdating(true);
try {
const res = await fetch(`${API_BASE}/api/v1/project-management/tasks/${taskId}/progress`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ progress: newProgress }),
});
if (!res.ok) throw new Error('更新进度失败');
await fetchTaskDetail();
} catch (err: any) {
alert(err.message);
} finally {
setUpdating(false);
}
};
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<p>加载中...</p>
</div>
);
}
return (
<div style={{ minHeight: '100vh', background: 'var(--bg-gray)' }}>
{/* Header */}
<header style={{
height: '60px',
background: 'var(--bg-white)',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
padding: '0 20px',
gap: '20px',
}}>
<Link href="/projects" style={{ fontSize: '18px', fontWeight: 'bold', color: 'var(--primary)', textDecoration: 'none' }}>
← 返回任务列表
</Link>
</header>
{/* Main */}
<main style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
{error && (
<div style={{
padding: '20px',
background: 'var(--bg-white)',
borderRadius: '8px',
border: '1px solid var(--border)',
textAlign: 'center',
}}>
<p style={{ color: 'var(--text-secondary)', marginBottom: '12px' }}>{error}</p>
<p style={{ fontSize: '14px', color: 'var(--text-secondary)' }}>
提示:需要在后端补充 <code>GET /api/v1/project-management/tasks/{'{task_id}'}</code> 接口
</p>
</div>
)}
{task && (
<div style={{ display: 'grid', gap: '20px' }}>
{/* 任务基本信息 */}
<div style={{ background: 'var(--bg-white)', padding: '24px', borderRadius: '8px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 'bold' }}>{task.name}</h1>
<button
onClick={() => setShowEditForm(!showEditForm)}
style={{
padding: '6px 12px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'white',
cursor: 'pointer',
}}
>
{showEditForm ? '取消编辑' : '编辑任务'}
</button>
</div>
{showEditForm ? (
<EditTaskForm
taskId={taskId}
initialData={{
name: task.name,
description: task.description,
priority: task.priority,
assignee_user_id: task.assignee_user_id,
}}
onSuccess={() => {
setShowEditForm(false);
fetchTaskDetail();
}}
onCancel={() => setShowEditForm(false)}
/>
) : (
<>
<p style={{ color: 'var(--text-secondary)', lineHeight: '1.6', marginBottom: '20px' }}>
{task.description || '暂无描述'}
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '20px', paddingTop: '20px', borderTop: '1px solid var(--border)' }}>
<div>
<span style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>状态</span>
<select
value={task.status}
onChange={(e) => updateStatus(e.target.value)}
disabled={updating}
style={{
display: 'block',
marginTop: '8px',
padding: '6px 10px',
border: '1px solid var(--border)',
borderRadius: '4px',
fontWeight: 'bold',
cursor: updating ? 'not-allowed' : 'pointer',
}}
>
<option value="pending">待开始</option>
<option value="in_progress">进行中</option>
<option value="completed">已完成</option>
<option value="blocked">阻塞</option>
<option value="cancelled">已取消</option>
</select>
</div>
<div>
<span style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>优先级</span>
<p style={{ fontWeight: 'bold', marginTop: '8px' }}>{task.priority}</p>
</div>
<div>
<span style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>进度</span>
<div style={{ marginTop: '8px', display: 'flex', alignItems: 'center', gap: '10px' }}>
<input
type="range"
min="0"
max="100"
value={task.progress}
onChange={(e) => updateProgress(parseFloat(e.target.value))}
disabled={updating}
style={{ flex: 1, cursor: updating ? 'not-allowed' : 'pointer' }}
/>
<span style={{ fontWeight: 'bold', minWidth: '45px' }}>{task.progress}%</span>
</div>
</div>
</div>
</>
)}
</div>
{/* 问题卡点列表 */}
<div style={{ background: 'var(--bg-white)', padding: '24px', borderRadius: '8px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h2 style={{ fontSize: '18px', fontWeight: 'bold' }}>问题卡点 ({issues.length})</h2>
<button
onClick={() => setShowIssueForm(!showIssueForm)}
style={{
padding: '6px 12px',
borderRadius: '4px',
border: 'none',
background: 'var(--primary)',
color: 'white',
cursor: 'pointer',
fontSize: '14px',
}}
>
{showIssueForm ? '取消' : '+ 添加问题'}
</button>
</div>
{showIssueForm && (
<div style={{ marginBottom: '16px' }}>
<CreateIssueForm
taskId={taskId}
projectId={task.project_id}
workspaceId={task.workspace_id}
onSuccess={() => {
setShowIssueForm(false);
fetchTaskIssues();
}}
onCancel={() => setShowIssueForm(false)}
/>
</div>
)}
{issues.length === 0 ? (
<p style={{ color: 'var(--text-secondary)' }}>暂无问题</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{issues.map(issue => (
<div key={issue.id} style={{
padding: '12px',
border: '1px solid var(--border)',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}>
<span style={{ fontSize: '20px' }}>{issue.resolved ? '🟢' : '🔴'}</span>
<div style={{ flex: 1 }}>
<p style={{ fontWeight: '500' }}>{issue.title}</p>
{issue.description && (
<p style={{ fontSize: '14px', color: 'var(--text-secondary)', marginTop: '4px' }}>
{issue.description}
</p>
)}
</div>
{!issue.resolved && (
<button
onClick={() => resolveIssue(issue.id)}
disabled={updating}
style={{
padding: '4px 12px',
borderRadius: '4px',
border: '1px solid var(--success)',
background: 'white',
color: 'var(--success)',
cursor: updating ? 'not-allowed' : 'pointer',
fontSize: '12px',
}}
>
标记已解决
</button>
)}
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
{issue.resolved ? '已解决' : '未解决'}
</span>
</div>
))}
</div>
)}
</div>
</div>
)}
</main>
</div>
);
}
-10
View File
@@ -1,10 +0,0 @@
import { expect, test } from '@playwright/test';
test.describe('Authentication page', () => {
test('renders login form', async ({ page }) => {
await page.goto('/login');
await expect(page.getByPlaceholder('邮箱')).toBeVisible();
await expect(page.getByPlaceholder('密码')).toBeVisible();
await expect(page.getByRole('button', { name: /登\s*录/ })).toBeVisible();
});
});
-112
View File
@@ -1,112 +0,0 @@
import { expect, test } from '@playwright/test';
const PASSWORD = 'SmokePass123!';
type WorkspaceResponse = { id?: string; workspace_id?: string };
type ProjectResponse = { id: string };
type LibraryResponse = { id: string };
type GenerationTaskResponse = { id: string; status: string; progress: number; result_count: number; error_message?: string | null };
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);
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 apiBase = '/api/v1';
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 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;
await page.goto('/login');
await page.getByPlaceholder('邮箱').fill(email);
await page.getByPlaceholder('密码').fill(PASSWORD);
await page.getByRole('button', { name: /登\s*录/ }).click();
await expect(page).toHaveURL(/\/workspaces/, { timeout: 20_000 });
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();
const createTaskResponsePromise = page.waitForResponse(
(response) => response.url().includes('/api/v1/generation/tasks') && response.request().method() === 'POST',
{ timeout: 30_000 }
);
await page.getByRole('button', { name: '发起生成' }).click();
const createTaskResponse = await createTaskResponsePromise;
expect(createTaskResponse.status(), await createTaskResponse.text()).toBe(200);
const createdTask = (await createTaskResponse.json()) as GenerationTaskResponse;
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.error_message || ''}`;
},
{ timeout: 90_000, intervals: [1_000, 2_000, 5_000] }
)
.toMatch(/^completed:[1-9]\d*:/);
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`);
await expect(page.getByText(generatedVideo.name, { exact: true })).toBeVisible({ timeout: 20_000 });
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);
});
});
-114
View File
@@ -1,114 +0,0 @@
import { expect, test } from '@playwright/test';
const PASSWORD = 'SmokePass123!';
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);
const suffix = Date.now().toString(36);
const email = `e2e-mov-${suffix}@example.com`;
const username = `e2e_mov_${suffix}`;
const apiBase = '/api/v1';
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 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.goto('/login');
await page.getByPlaceholder('邮箱').fill(email);
await page.getByPlaceholder('密码').fill(PASSWORD);
await page.getByRole('button', { name: /登\s*录/ }).click();
await expect(page).toHaveURL(/\/workspaces/, { timeout: 20_000 });
await page.goto(`/projects/${projectData.id}/assets`);
const uploadButton = page.getByRole('button', { name: /点击或拖拽文件到这里批量上传素材/ });
await expect(uploadButton).toBeEnabled({ timeout: 20_000 });
const fileInput = page.locator('.ant-upload input[type="file"]:not([disabled])').first();
await expect(fileInput).toBeAttached({ timeout: 20_000 });
const completeResponsePromise = page.waitForResponse(
(response) => response.url().includes('/api/v1/upload/direct/complete') && response.request().method() === 'POST',
{ timeout: 60_000 }
);
await fileInput.setInputFiles({
name: 'e2e-sample.MOV',
mimeType: 'video/quicktime',
buffer: Buffer.from('playwright mov upload smoke'),
});
const completeResponse = await completeResponsePromise;
expect(completeResponse.status(), await completeResponse.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.getByRole('cell', { name: 'e2e-sample.MOV', exact: true })).toBeVisible({ timeout: 20_000 });
await expect(page.getByText(/素材列表加载失败|上传失败/)).toHaveCount(0);
});
});
-8
View File
@@ -1,8 +0,0 @@
import { expect, test } from '@playwright/test';
test.describe('Subscription route guard', () => {
test('redirects anonymous users to login', async ({ page }) => {
await page.goto('/subscription');
await expect(page).toHaveURL(/\/login/);
});
});
-8
View File
@@ -1,8 +0,0 @@
import { expect, test } from '@playwright/test';
test.describe('Workspace route guard', () => {
test('redirects anonymous users to login', async ({ page }) => {
await page.goto('/workspaces');
await expect(page).toHaveURL(/\/login/);
});
});
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>小虾 SaaS - 自动化视频剪辑平台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-4
View File
@@ -1,4 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// This file is auto-used by Next.js TypeScript projects.
-6
View File
@@ -1,6 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
module.exports = nextConfig;
-7021
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More