Compare commits
283 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| babdb06287 | |||
| 1d638027bf | |||
| e63d5f07c4 | |||
| dc89f66bfd | |||
| 36a0ae1afd | |||
| 93d212c1c4 | |||
| 176914b3a4 | |||
| feb410b9b6 | |||
| 2cccc1b1d1 | |||
| 9743123abc | |||
| 0354ad65d2 | |||
| f7e3a7a5a2 | |||
| 136c40360c | |||
| 719b090217 | |||
| 18a3490eda | |||
| 5fa4e2573e | |||
| d00d63f14d | |||
| 90911d21fc | |||
| fb7024c359 | |||
| 4c396b2a7b | |||
| cb2e20ffd4 | |||
| fe7a8ee6fb | |||
| 36fcc71c80 | |||
| a9fb1f89e7 | |||
| 61163eadca | |||
| 1245f7b270 | |||
| 313bb1b2cc | |||
| 386ce76761 | |||
| ca4fb8791b | |||
| 8a4cab3cc7 | |||
| 7716371c8d | |||
| ccc6aa27e8 | |||
| 91ec8559b6 | |||
| 2c8fe8f9d6 | |||
| 9517871afe | |||
| a1cf1ddd9d | |||
| 04861a48cb | |||
| d8bbc1b245 | |||
| a23987ae22 | |||
| 4344bfad3c | |||
| ca8647bdc7 | |||
| c5451678e8 | |||
| a670a0c793 | |||
| f5827b52d3 | |||
| 9ac9ba8184 | |||
| 631328ea7c | |||
| 668838f7fb | |||
| 90071ce495 | |||
| dce7928d90 | |||
| edf87da8b5 | |||
| 74f57c52bc | |||
| e5ca464e62 | |||
| 072eccebef | |||
| b0d897ed32 | |||
| 59291b0e8f | |||
| 1a9d862584 | |||
| d60ae7dafa | |||
| f19f649849 | |||
| e844a74a02 | |||
| c5042f8a51 | |||
| 70e58ccf8e | |||
| a7f816fe5b | |||
| a1bc6eba90 | |||
| 11c203eef6 | |||
| bab6a812a6 | |||
| 786a7c30fc | |||
| f22af7ee4c | |||
| 1f89f8edfe | |||
| 47a9d7be9c | |||
| 114bc8aff2 | |||
| 1c9a041a31 | |||
| 95387c4096 | |||
| 89e5b4db5d | |||
| 747a1c0ad3 | |||
| ee6a98398f | |||
| 1708401bcd | |||
| e36347210c | |||
| fd38823fa4 | |||
| 683f879bdf | |||
| 6eba0cd296 | |||
| 00c3dfb7da | |||
| 9b6aab71d8 | |||
| fa19a34f7a | |||
| e36af9e7a9 | |||
| a20267091a | |||
| 93ba337538 | |||
| c5321f4f12 | |||
| 38b5b60160 | |||
| 2e863c8b43 | |||
| b084e5b468 | |||
| 2c3856a5a0 | |||
| 1c10c2d8af | |||
| 4a8703b037 | |||
| 309906cb53 | |||
| f89a059c6b | |||
| 10256a1b74 | |||
| 4ac7a3c5b8 | |||
| ac12d98c7f | |||
| 48d71ecffb | |||
| b79d6718d6 | |||
| e62036da45 | |||
| 427d999c62 | |||
| f9f9afed7a | |||
| a54af57881 | |||
| 0501e96cd2 | |||
| e5224d090b | |||
| 50fb2c04df | |||
| b965d8d766 | |||
| f474b97a57 | |||
| e2f0e6faf9 | |||
| ebb141994f | |||
| fd701857ec | |||
| a988f5935a | |||
| cb42f0eda4 | |||
| 29f74f0861 | |||
| 93ebce18ca | |||
| 5c1773e2f1 | |||
| 8cba7b6c07 | |||
| 391abdff2a | |||
| 925365fcb5 | |||
| f0088d0c26 | |||
| 71d7913f2f | |||
| ba06c95b16 | |||
| df2a6d68ec | |||
| 569d7ff8a2 | |||
| e37eec67dc | |||
| cf1d187b6d | |||
| bb2ae19091 | |||
| 32d6d0eb9d | |||
| 8df0a476c0 | |||
| e122e084c0 | |||
| 3b6b16d0e1 | |||
| 7269ed6cc1 | |||
| a97106d9b8 | |||
| acaff441bc | |||
| e7b1d3eeff | |||
| 8120597ae1 | |||
| 50d2d9642a | |||
| ae13650bfd | |||
| e819f6f89b | |||
| 9656588bf2 | |||
| bd35e57147 | |||
| 283b2e7510 | |||
| dce28d5bb8 | |||
| c22f5e9c70 | |||
| f38df083db | |||
| b548d18e4f | |||
| 8ad7fd09d5 | |||
| 4ddaafbdef | |||
| 55011c06ca | |||
| a1fce0d467 | |||
| d9822c20ff | |||
| a339053733 | |||
| e5d5ae4ce7 | |||
| 7e98f917d1 | |||
| 8ac0d0da3e | |||
| bd078438c0 | |||
| 938ba71869 | |||
| e67f2a17c6 | |||
| 53666760fc | |||
| 6cd6a5d753 | |||
| 1fd5abc5cf | |||
| 7c99f26667 | |||
| 454c561f71 | |||
| bd0188c737 | |||
| bdd42e0d78 | |||
| c0f8c82cf1 | |||
| c710a1390b | |||
| 5b0c85685c | |||
| 4aa8bf7a28 | |||
| 11c680a3a1 | |||
| e5fd3c8ad1 | |||
| 53ac0b94c5 | |||
| 6c7bb6cfba | |||
| 18d5dcf5f4 | |||
| cf62c9b8a6 | |||
| 8b378ea6c9 | |||
| 63d09fac2b | |||
| 86135037f3 | |||
| af487c5f6f | |||
| 4e0291fb44 | |||
| 2c2bd618fa | |||
| b06201e503 | |||
| a027534cc4 | |||
| c7d4f21f3d | |||
| 28e2c12b7f | |||
| ed0250f279 | |||
| 495f0e36e1 | |||
| ebcd2ceb63 | |||
| 2cda8dec39 | |||
| 3bf90f8614 | |||
| ce891e8515 | |||
| e32c84da1c | |||
| b131b82c21 | |||
| 490c432e17 | |||
| 7970359543 | |||
| 4f77025f5d | |||
| 7dfedd3245 | |||
| 7380644d69 | |||
| 45b3659f31 | |||
| 432c7666a9 | |||
| fee8255480 | |||
| bc5e47528c | |||
| ebebef7341 | |||
| 625ecdd8a6 | |||
| f49e30bbb6 | |||
| 76f23b4058 | |||
| 902e9d8dfb | |||
| f8742351e3 | |||
| 9c9818a4b1 | |||
| 9071d505a5 | |||
| 4a81eb6130 | |||
| 0acba78f7b | |||
| 16e1de3317 | |||
| 0f94a76258 | |||
| bf0a66388c | |||
| 3f3821581c | |||
| 8095539dd4 | |||
| 04cfa5e7a8 | |||
| 4495d0c53d | |||
| 50b1584cbe | |||
| b8c7edc14f | |||
| 2f55b26bb2 | |||
| ddf395b0c1 | |||
| aeb7b52263 | |||
| 20f7145981 | |||
| b8a3b40e0b | |||
| 8647f5614c | |||
| e7c8fdbd79 | |||
| 1eb1fa1fb9 | |||
| f0cf4e2c14 | |||
| 6e1b0786d8 | |||
| fa2c9968e6 | |||
| 585e66c257 | |||
| a040f33d6a | |||
| 6f3c881322 | |||
| ce33eb900d | |||
| 0bb499e039 | |||
| a8a1f6d290 | |||
| bcc3089f53 | |||
| c44ec56207 | |||
| 90c34949b8 | |||
| 19ab0e2fc4 | |||
| 2b4eeadbbb | |||
| 1aa111ffd2 | |||
| 651bbd0142 | |||
| 424327c762 | |||
| eed05e56c8 | |||
| c72be74727 | |||
| 402a767d33 | |||
| 16f715a1e0 | |||
| e344fe2e9e | |||
| 4f1186c75e | |||
| 145657c1b8 | |||
| a8f521e63b | |||
| c6f21d2703 | |||
| a0fee54e43 | |||
| 6eb394d6ad | |||
| 0217c8ce28 | |||
| c187e3eee8 | |||
| ca169de6f9 | |||
| ef4cd8aedc | |||
| bda4db5e5b | |||
| 9dd36f308f | |||
| 355dd0ffbb | |||
| 2c78407f7f | |||
| 35a29d85d3 | |||
| d3d90d3775 | |||
| 33fb16a75a | |||
| b47cfa549d | |||
| b9a95eea68 | |||
| f13c7ab9af | |||
| 2a7201a2a4 | |||
| 38df02514a | |||
| e786de7349 | |||
| 99e72cdcba | |||
| 7f416700de | |||
| 02000b9889 | |||
| 6782f79df0 | |||
| 35f9ff1d82 | |||
| 823faa4a52 | |||
| be4b30f4da | |||
| 749ef7f55a |
@@ -1 +0,0 @@
|
||||
# CI trigger Fri Jun 26 09:53:28 PM CST 2026
|
||||
Executable → Regular
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
# ==================== 应用配置 ====================
|
||||
APP_NAME=小虾 SaaS
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
BASE_URL=http://localhost:3000
|
||||
|
||||
# ==================== 数据库配置 ====================
|
||||
DATABASE_URL=postgresql://xiaoxia_user:your_password@localhost:5432/xiaoxia_saas
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# =======================
|
||||
# 生产环境配置
|
||||
# =======================
|
||||
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
|
||||
@@ -0,0 +1,85 @@
|
||||
# =======================
|
||||
# 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
|
||||
@@ -1,32 +1,3 @@
|
||||
# Normalize text files automatically
|
||||
* text=auto
|
||||
|
||||
# Source files use LF
|
||||
*.py text eol=lf
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.json text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.md text eol=lf
|
||||
*.sh text eol=lf
|
||||
infra/docker/*.sh text eol=lf
|
||||
scripts/*.sh text eol=lf
|
||||
|
||||
# Windows scripts use CRLF
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
# Binary files
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
name: Auto Merge PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Auto merge develop PRs
|
||||
run: |
|
||||
bash scripts/auto_merge_prs.sh develop
|
||||
|
||||
- name: Auto merge main PRs (release only)
|
||||
run: |
|
||||
bash scripts/auto_merge_prs.sh main
|
||||
+25
-105
@@ -17,36 +17,39 @@ on:
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Code Quality And Tests
|
||||
runs-on: runtime-builder:host
|
||||
container: localhost:5000/xiaoxia-ci-python:3.12
|
||||
runs-on: ubuntu-latest
|
||||
container: xiaoxia-ci-python:3.12
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python - <<'PY'
|
||||
import io, os, tarfile, urllib.request
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
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:
|
||||
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):]
|
||||
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:
|
||||
tar.extract(member, '.')
|
||||
archive.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Verify CI environment
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python --version
|
||||
python -m pip --version
|
||||
python -m black --version
|
||||
@@ -54,35 +57,27 @@ jobs:
|
||||
python -m flake8 --version
|
||||
bandit --version
|
||||
pytest --version
|
||||
echo "CI environment is ready"
|
||||
echo "✅ Prebuilt CI environment is ready"
|
||||
|
||||
- name: Run code quality checks
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m compileall -q alembic apps packages tests scripts
|
||||
python -m black --check alembic apps packages tests scripts
|
||||
python -m isort --check-only alembic apps packages tests scripts
|
||||
python -m flake8 apps packages tests --count --statistics
|
||||
|
||||
- name: Run security scan
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
bandit -r apps packages -q
|
||||
|
||||
- name: Validate release scripts syntax
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
bash -n scripts/backup_postgres.sh
|
||||
bash -n scripts/restore_postgres_plan.sh
|
||||
bash -n scripts/init_production_env.sh
|
||||
|
||||
- name: Validate Alembic migrations
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas \
|
||||
python -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql
|
||||
test -s /tmp/alembic-upgrade.sql
|
||||
@@ -90,87 +85,12 @@ jobs:
|
||||
python scripts/check_schema_metadata.py
|
||||
|
||||
- name: Run tests
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/unit -q
|
||||
python -m pytest tests -q
|
||||
|
||||
- name: Build summary
|
||||
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
echo "Build completed successfully!"
|
||||
echo "✅ Build completed successfully!"
|
||||
echo "Branch: ${GITHUB_REF_NAME}"
|
||||
echo "Commit: ${GITHUB_SHA}"
|
||||
|
||||
frontend-lint:
|
||||
name: Frontend Lint
|
||||
runs-on: runtime-builder:host
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
archive_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz"
|
||||
wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url"
|
||||
tar -xzf /tmp/repo.tar.gz --strip-components=1 -C .
|
||||
rm -f /tmp/repo.tar.gz
|
||||
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
--pull=never \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npm ci'
|
||||
|
||||
- name: Run ESLint
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
--pull=never \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npx eslint src --ext .ts,.tsx --max-warnings 0'
|
||||
|
||||
- name: Run TypeScript type check
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
--pull=never \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npx tsc --noEmit'
|
||||
|
||||
- name: Run Prettier check
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
--pull=never \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npx prettier --check "src/**/*.{ts,tsx,css,md}"'
|
||||
|
||||
- name: Run Vitest tests
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
--pull=never \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npx vitest run'
|
||||
|
||||
+78
-79
@@ -2,15 +2,17 @@ name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop, "feature/**" ]
|
||||
branches: [ main, develop ]
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
deploy-staging:
|
||||
name: Deploy Staging
|
||||
runs-on: runtime-builder:host
|
||||
if: github.ref_name == 'main' || github.ref_name == 'develop' || startsWith(github.ref_name, 'feature/')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: docker:27-cli
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -24,78 +26,62 @@ jobs:
|
||||
tar -xzf /tmp/repo.tar.gz --strip-components=1 -C .
|
||||
rm -f /tmp/repo.tar.gz
|
||||
|
||||
- name: Build staging web artifact
|
||||
- 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 \
|
||||
--pull=never \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npm ci && npm run build'
|
||||
docker build --pull=false \
|
||||
-f infra/docker/web-artifact.Dockerfile \
|
||||
-t "xiaoxia-saas-web:staging-${GITHUB_SHA}" \
|
||||
.
|
||||
test -f apps/web/dist/index.html
|
||||
-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: Package staging release artifact
|
||||
- name: Verify staging health
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
rm -rf dist/staging-artifacts
|
||||
mkdir -p dist/staging-artifacts
|
||||
tar --exclude=.git --exclude=apps/web/node_modules --exclude=./dist \
|
||||
-czf dist/staging-artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz .
|
||||
docker save -o "dist/staging-artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" "xiaoxia-saas-web:staging-${GITHUB_SHA}"
|
||||
|
||||
- name: Upload staging artifact to business host
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
staging_user="${STAGING_SSH_USER:-root}"
|
||||
mkdir -p ~/.ssh
|
||||
if [ -n "${STAGING_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
printf '%s\n' "$STAGING_SSH_KEY" > "$key_path"
|
||||
chmod 600 "$key_path"
|
||||
else
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
test -f "$key_path"
|
||||
fi
|
||||
ssh-keyscan -H "$staging_host" >> ~/.ssh/known_hosts
|
||||
ssh -i "$key_path" "$staging_user@$staging_host" "mkdir -p /var/lib/xiaoxia-saas-staging/artifacts"
|
||||
scp -i "$key_path" "dist/staging-artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz" \
|
||||
"$staging_user@$staging_host:/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz"
|
||||
scp -i "$key_path" "dist/staging-artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" \
|
||||
"$staging_user@$staging_host:/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar"
|
||||
|
||||
- name: Deploy staging stack on business host
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
staging_user="${STAGING_SSH_USER:-root}"
|
||||
if [ -n "${STAGING_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
else
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
fi
|
||||
echo 'c2V0IC1ldQphcnRpZmFjdD0iL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvYXJ0aWZhY3RzL3hpYW94aWEtc3RhZ2luZy0ke0dJVEhVQl9TSEF9LnRhci5neiIKaW1hZ2VfdGFyPSIvdmFyL2xpYi94aWFveGlhLXNhYXMtc3RhZ2luZy9hcnRpZmFjdHMveGlhb3hpYS13ZWItc3RhZ2luZy0ke0dJVEhVQl9TSEF9LnRhciIKdGVzdCAtZiAiJGFydGlmYWN0Igp0ZXN0IC1mICIkaW1hZ2VfdGFyIgp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nLy5lbnYKZG9ja2VyIGxvYWQgLWkgIiRpbWFnZV90YXIiCnJtIC1yZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtc3RhZ2luZy9yZXBvCm1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8KdGFyIC14emYgIiRhcnRpZmFjdCIgLUMgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwbwp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8vYXBwcy93ZWIvZGlzdC9pbmRleC5odG1sCmNwIC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nLy5lbnYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwby8uZW52CmNobW9kICt4IC92YXIvbGliL3hpYW94aWEtc2Fhcy1zdGFnaW5nL3JlcG8vaW5mcmEvZG9ja2VyL2RlcGxveS1zdGFnaW5nLnNoCldFQl9JTUFHRT0ieGlhb3hpYS1zYWFzLXdlYjpzdGFnaW5nLSR7R0lUSFVCX1NIQX0iIEhPU1RfUFJFRklYPSBXRUJfUE9SVD0zMDAxIFJFQlVJTERfQkFDS0VORD0wIEJVSUxEX1dFQj0wIFJVTl9NSUdSQVRJT05TPTAgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvcmVwby9pbmZyYS9kb2NrZXIvZGVwbG95LXN0YWdpbmcuc2gKaT0wCndoaWxlIFsgIiRpIiAtbHQgMzAgXTsgZG8KICBpZiB3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAwL2hlYWx0aDsgdGhlbgogICAgZXhpdCAwCiAgZmkKICBpPSQoKGkgKyAxKSkKICBzbGVlcCAyCmRvbmUKZXhpdCAxCg==' | base64 -d | ssh -i "$key_path" "$staging_user@$staging_host" "GITHUB_SHA='${GITHUB_SHA}' sh"
|
||||
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:host
|
||||
runs-on: runtime-builder
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
steps:
|
||||
@@ -117,21 +103,15 @@ jobs:
|
||||
chmod +x scripts/build_release_images.sh
|
||||
scripts/build_release_images.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Build production web artifact
|
||||
- name: Build web artifact
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
--pull=never \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npm ci && npm run build'
|
||||
docker build --pull=false \
|
||||
-f infra/docker/web-artifact.Dockerfile \
|
||||
-t "xiaoxia-saas-web:${GITHUB_REF_NAME}" \
|
||||
.
|
||||
test -f apps/web/dist/index.html
|
||||
|
||||
- name: Package release source artifact
|
||||
shell: sh
|
||||
@@ -140,7 +120,6 @@ jobs:
|
||||
mkdir -p dist/release-artifacts
|
||||
tar --exclude=.git --exclude=apps/web/node_modules --exclude=./dist \
|
||||
-czf "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" .
|
||||
docker save -o "dist/release-artifacts/xiaoxia-web-${GITHUB_REF_NAME}.tar" "xiaoxia-saas-web:${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Upload runtime image and release artifacts
|
||||
shell: sh
|
||||
@@ -166,12 +145,10 @@ jobs:
|
||||
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/runtime-images-${GITHUB_REF_NAME}.tar"
|
||||
scp -i "$key_path" "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" \
|
||||
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/release-${GITHUB_REF_NAME}.tar.gz"
|
||||
scp -i "$key_path" "dist/release-artifacts/xiaoxia-web-${GITHUB_REF_NAME}.tar" \
|
||||
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/web-${GITHUB_REF_NAME}.tar"
|
||||
|
||||
deploy-production:
|
||||
name: Deploy Production
|
||||
runs-on: runtime-builder:host
|
||||
runs-on: runtime-builder
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: build-production-runtime-images
|
||||
|
||||
@@ -196,11 +173,34 @@ jobs:
|
||||
test -f "$key_path"
|
||||
fi
|
||||
ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts
|
||||
echo 'c2V0IC1ldQpyZWxlYXNlX3Rhcj0iL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVsZWFzZS0ke1JFTEVBU0VfVkVSU0lPTn0udGFyLmd6Igp0ZXN0IC1mICIkcmVsZWFzZV90YXIiCnRlc3QgLWYgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3J1bnRpbWUtaW1hZ2VzLSR7UkVMRUFTRV9WRVJTSU9OfS50YXIiCnRlc3QgLWYgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3dlYi0ke1JFTEVBU0VfVkVSU0lPTn0udGFyIgpta2RpciAtcCAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbgpvbGRfYXNzZXRzX2Rpcj0iL3RtcC94aWFveGlhLXByZXZpb3VzLXdlYi1hc3NldHMtJHtSRUxFQVNFX1ZFUlNJT059IgpybSAtcmYgIiRvbGRfYXNzZXRzX2RpciIKbWtkaXIgLXAgIiRvbGRfYXNzZXRzX2RpciIKaWYgZG9ja2VyIGluc3BlY3QgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICBkb2NrZXIgY3AgeGlhb3hpYS13ZWItcHJvZHVjdGlvbjovdXNyL3NoYXJlL25naW54L2h0bWwvYXNzZXRzLy4gIiRvbGRfYXNzZXRzX2RpciIvIDI+L2Rldi9udWxsIHx8IHRydWUKZmkKaWYgWyAtZCAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvL2FwcHMvd2ViL2Rpc3QvYXNzZXRzIF07IHRoZW4KICBjcCAtYSAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvL2FwcHMvd2ViL2Rpc3QvYXNzZXRzLy4gIiRvbGRfYXNzZXRzX2RpciIvCmZpCnJtIC1yZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvCm1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8KdGFyIC14emYgIiRyZWxlYXNlX3RhciIgLUMgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwbwp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9pbmRleC5odG1sCmlmIFsgLWQgIiRvbGRfYXNzZXRzX2RpciIgXTsgdGhlbgogIG1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMKICBmb3IgYXNzZXQgaW4gIiRvbGRfYXNzZXRzX2RpciIvKjsgZG8KICAgIFsgLWUgIiRhc3NldCIgXSB8fCBjb250aW51ZQogICAgbmFtZT0iJChiYXNlbmFtZSAiJGFzc2V0IikiCiAgICBpZiBbICEgLWUgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMvJG5hbWUiIF07IHRoZW4KICAgICAgY3AgLWEgIiRhc3NldCIgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMvJG5hbWUiCiAgICBmaQogIGRvbmUKICBybSAtcmYgIiRvbGRfYXNzZXRzX2RpciIKZmkKdGVzdCAtZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi8uZW52CmNwIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uLy5lbnYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby8uZW52CkhPU1RfUFJFRklYPSBXRUJfSU1BR0U9InhpYW94aWEtc2Fhcy13ZWI6JHtSRUxFQVNFX1ZFUlNJT059IiBXRUJfSU1BR0VfVEFSPSIvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi93ZWItJHtSRUxFQVNFX1ZFUlNJT059LnRhciIgc2ggL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby9pbmZyYS9kb2NrZXIvZGVwbG95LXByb2R1Y3Rpb24uc2gKaT0wCndoaWxlIFsgIiRpIiAtbHQgMzAgXTsgZG8KICBpZiB3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAxL2hlYWx0aDsgdGhlbgogICAgZXhpdCAwCiAgZmkKICBpPSQoKGkgKyAxKSkKICBzbGVlcCAyCmRvbmUKZXhpdCAxCg==' | base64 -d | ssh -i "$key_path" "$production_user@$production_host" "RELEASE_VERSION='${GITHUB_REF_NAME}' sh"
|
||||
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
|
||||
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
|
||||
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:host
|
||||
runs-on: runtime-builder
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: deploy-production
|
||||
|
||||
@@ -222,9 +222,8 @@ jobs:
|
||||
set -eu
|
||||
docker run --rm \
|
||||
-e E2E_BASE_URL=https://saas.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://api.xiaoxiajianji.com/api/v1 \
|
||||
-e E2E_BROWSER_CHANNEL=chromium \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
mcr.microsoft.com/playwright:v1.45.0-jammy \
|
||||
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'
|
||||
sh -lc 'npm ci && npx playwright test --project=chromium e2e/core-upload.spec.ts'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Tests
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: xiaoxia-ci-python:3.12
|
||||
image: python:3.12-slim
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -58,12 +58,12 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/unit -q
|
||||
python -m pytest tests/integration/ -v --cov=packages --cov=apps --cov-report=xml --cov-report=term
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: xiaoxia-ci-python:3.12
|
||||
image: python:3.12-slim
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -104,10 +104,16 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m black --check alembic apps packages tests scripts
|
||||
python -m black --check packages/ apps/ tests/
|
||||
|
||||
- name: Run Flake8
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m flake8 apps packages tests --count --statistics
|
||||
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
|
||||
|
||||
+2
-13
@@ -20,9 +20,9 @@ ruff_cache/
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
.env.staging
|
||||
.env.production # 生产环境配置不提交(包含敏感信息)
|
||||
!.env.example
|
||||
!.env.staging # staging 配置可以提交
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
@@ -38,14 +38,3 @@ temp/
|
||||
# Build / runtime artifacts
|
||||
build/
|
||||
.runtime/
|
||||
|
||||
# SQLite databases
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Tracker temp files
|
||||
tracker_tasks.json
|
||||
|
||||
# Schema metadata snapshot
|
||||
schema-metadata-snapshot.json
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
# Errors
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 2026-06-24 ProjectAssets unsafe return replacement
|
||||
- Context: Real SaaS UI rollout from V21 prototype.
|
||||
- Error: Replacing JSX return by broad string/script inserted helper functions inside an effect and broke TypeScript syntax.
|
||||
- Fix: Reverted ProjectAssets.tsx to stable git version; continue with smaller, scoped edits or separate page files.
|
||||
- Lesson: For large TSX pages with effects, avoid broad find/replace from first return; use component-scope anchors or rewrite whole file intentionally.
|
||||
|
||||
|
||||
## 2026-06-24 E2E API unavailable
|
||||
- Context: V21 UI acceptance run.
|
||||
- Failure: Playwright core upload/generation/titles failed at auth/register with 500 because Vite proxy could not connect to local API (ECONNREFUSED).
|
||||
- Fix path: Start local API or point E2E_BASE_URL/API proxy to staging test environment before rerunning core E2E.
|
||||
|
||||
|
||||
## ERR-20260624-gitea-runner-fetch-task-404
|
||||
|
||||
**Logged**: 2026-06-24T19:27+08:00
|
||||
**Area**: infra/ci
|
||||
|
||||
### Summary
|
||||
Gitea Actions runner is running but repeatedly logs ailed to fetch task: unimplemented: 404 Not Found; develop pushes appear in Actions UI but staging repo is not updated.
|
||||
|
||||
### Impact
|
||||
CI/CD-first release is blocked until runner/Gitea endpoint compatibility or registration is fixed.
|
||||
|
||||
### Next Action
|
||||
Check act_runner config/registration, Gitea actions endpoint compatibility, runner version, and service URL.
|
||||
|
||||
|
||||
## [ERR-20260624-STAGING-WEB-BUILD-ON-BUSINESS-SERVER] deploy
|
||||
|
||||
**Logged**: 2026-06-24T22:50:00+08:00
|
||||
**Priority**: critical
|
||||
**Status**: pending
|
||||
**Area**: infra
|
||||
|
||||
### Summary
|
||||
Staging artifact upgrade attempted `npm ci && npm run build` on the wrong server path and overloaded the machine.
|
||||
|
||||
### Details
|
||||
The deploy workflow change `3bffa3c fix(deploy): build staging web artifact` added a staging step that ran Node build via Docker on the runner/deploy host. SSH later connected at TCP level but timed out during banner exchange; public HTTPS/health also timed out. The dangerous workflow was reverted by `b01ae28 Revert "fix(deploy): build staging web artifact"`.
|
||||
|
||||
### Suggested Action
|
||||
Recover host first, stop residual build/runner tasks, verify production/staging health, then reimplement artifact deploy using isolated builder/CI server and hard resource limits. Add explicit guardrails so business server cannot run npm/pip/docker builds.
|
||||
|
||||
### Metadata
|
||||
- Source: error
|
||||
- Related Files: .gitea/workflows/deploy.yml, docs/V21-UI-ACCEPTANCE-CHECKLIST.md
|
||||
- Tags: outage, ci-cd, resource-isolation, rollback
|
||||
---
|
||||
|
||||
## 2026-06-25 - Alembic command must use repo root in API container
|
||||
|
||||
- Failed command: docker compose exec api alembic upgrade head from mounted repo path inside staging deploy directory.
|
||||
- Error: No config file alembic.ini found because the API container workdir is /app/apps/api while alembic.ini is /app/alembic.ini.
|
||||
- Fix: run docker exec -w /app xiaoxia-api-staging alembic -c alembic.ini upgrade head for lightweight staging migrations.
|
||||
|
||||
|
||||
## 2026-06-25 - Windows workspace has no local sh/bash
|
||||
|
||||
- Failed command: sh -n infra/docker/deploy-production.sh / bash -n infra/docker/deploy-production.sh on Windows host.
|
||||
- Error: sh/bash command not found in the PowerShell runtime.
|
||||
- Fix: run POSIX shell syntax checks via an available Linux host/container, e.g. scp to xiaoxia-server and run sh -n on a temporary file.
|
||||
|
||||
|
||||
## 2026-06-25 - Protected main release must not be direct-merged locally
|
||||
|
||||
- Failed action: attempted local develop->main merge and tag push for v0.1.51.
|
||||
- Errors: main branch is protected from direct push; local main had divergence/conflicts; tag v0.1.51 was pushed from the wrong local main HEAD and then removed.
|
||||
- Fix: never tag production before protected main has accepted the release commit. Use PR/approved merge path or Gitea API merge, then tag the actual merged main commit.
|
||||
|
||||
|
||||
## 2026-06-25 - No local Gitea/GitHub CLI in Windows workspace
|
||||
|
||||
- Failed command: gh --version / tea --version / gitea --version during release automation.
|
||||
- Error: commands not found in PowerShell runtime.
|
||||
- Fix: use Gitea API/server-side tools when available, or the web PR flow for protected-branch releases.
|
||||
|
||||
|
||||
## 2026-06-25 - Gitea generated token returned API 401
|
||||
|
||||
- Failed operation: create release PR via server-side generated Gitea access token.
|
||||
- Error: API returned 401 on authenticated pull request query/create.
|
||||
- Fix: verify token output/scopes/API auth behavior before using; do not print secrets, and delete temporary tokens after failed attempts.
|
||||
|
||||
|
||||
## 2026-06-25 - Business Gitea host lacks runtime-builder SSH key for ref sync
|
||||
|
||||
- Failed command: git fetch from git.xiaoxiajianji.com:2222 inside /var/lib/gitea/data/gitea-repositories using /root/.ssh/xiaoxia_runtime_builder.
|
||||
- Error: identity file missing and Permission denied (publickey).
|
||||
- Fix: do not install keys ad hoc on the business host; use an already-authenticated local clone bundle or proper Git/Gitea maintenance path to sync refs.
|
||||
|
||||
|
||||
## 2026-06-25 - Non-ASCII comments in .gitattributes broke Git attribute parsing
|
||||
|
||||
- Error: Git printed 'is not a valid attribute name' for Chinese comment text in .gitattributes during merge/fetch operations.
|
||||
- Fix: keep .gitattributes comments/rules ASCII-only and preserve the LF/CRLF normalization semantics.
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
|
||||
## 2026-06-24 correction: strict V21 UI implementation
|
||||
- Category: correction
|
||||
- User correction: Real SaaS UI must strictly follow confirmed V21 prototype, not agent-designed approximations.
|
||||
- Specific issue: Chinese mojibake appeared; generated video library lacked built-in playable preview required by design.
|
||||
- Required behavior: Re-read confirmed prototype before UI implementation, map layout/function one-to-one, preserve approved layout and only adapt real data/API.
|
||||
|
||||
|
||||
## 2026-06-24 correction: do not ask for next step during auto-run
|
||||
- Category: correction
|
||||
- User correction: When there is an obvious next step in full-auto mode, do not ask; continue until done, validate, and deploy.
|
||||
- Required behavior: For V21 SaaS UI rollout, autonomously finish all remaining pages, then report concise results only.
|
||||
|
||||
|
||||
## [LRN-20260624-CI-SEPARATION] correction
|
||||
|
||||
**Logged**: 2026-06-24T22:50:00+08:00
|
||||
**Priority**: critical
|
||||
**Status**: pending
|
||||
**Area**: infra
|
||||
|
||||
### Summary
|
||||
Do not run CI/Web build on the business/production server; preserve the two-server responsibility split.
|
||||
|
||||
### Details
|
||||
User corrected that the project already had two servers and had already addressed mixed responsibilities. The failure happened because I ignored the established boundary and triggered `npm ci && npm run build` through the current runner/deploy path, which pressured the business server and caused SSH banner and public service timeouts. This is an execution drift, not a product-size problem.
|
||||
|
||||
### Suggested Action
|
||||
Before any deploy/build change, verify server roles and runner placement. CI/build must run on the CI/build server or isolated builder; business server may only receive built artifacts/images and restart services. Never reintroduce build workloads onto production/business host.
|
||||
|
||||
### Metadata
|
||||
- Source: user_feedback
|
||||
- Related Files: .gitea/workflows/deploy.yml, infra/docker/deploy-staging.sh
|
||||
- Tags: ci-cd, staging, production-safety, server-roles, no-drift
|
||||
- Pattern-Key: infra.separate_ci_from_business_server
|
||||
- Recurrence-Count: 1
|
||||
---
|
||||
@@ -1 +0,0 @@
|
||||
TRIGGER: 2026-06-25 16:20:18
|
||||
@@ -1,68 +1,70 @@
|
||||
# 小虾 SaaS - 自动化视频剪辑平台
|
||||
# 小虾 SaaS - 自动化剪辑 SaaS 平台
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://fastapi.tiangolo.com)
|
||||
[](https://www.postgresql.org/)
|
||||
|
||||
自动化视频剪辑 SaaS 平台,支持素材上传、AI 分类、智能剪辑计划生成、自动化视频合成与成片管理。
|
||||
一个功能完整、生产就绪的多租户 SaaS 平台,专为自动化视频剪辑服务设计。
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心功能
|
||||
## ✨ 核心特性
|
||||
|
||||
### 🎬 视频剪辑主链路
|
||||
- 素材上传(直传 OSS + 分片上传大文件,最大 2GB)
|
||||
- AI 智能分类与质量评分
|
||||
- 4 种剪辑模式:one_take / pip(画中画)/ voice_over(口播+B-roll)/ voice_pip
|
||||
- 剪辑计划模板 + 智能生成
|
||||
- 自动化视频合成任务(Celery 异步)
|
||||
- 成片下载与审核管理
|
||||
- 资产诊断(素材就绪度评估、缺口分析)
|
||||
### 🎬 视频剪辑主链路(Phase 7 已完成)
|
||||
- 素材上传与管理
|
||||
- AI 智能分类
|
||||
- 自动化视频生成
|
||||
- 成片下载与管理
|
||||
|
||||
### 🔐 认证系统
|
||||
- JWT Bearer Token 认证
|
||||
- 邮箱注册 + 邮箱验证
|
||||
- 密码重置(邮箱找回)
|
||||
### 🔐 完整的认证系统
|
||||
- JWT 认证(access + refresh token)
|
||||
- 邮箱验证和密码重置
|
||||
- Session 管理
|
||||
- bcrypt 密码加密
|
||||
|
||||
### 📋 项目管理
|
||||
- 项目 CRUD + 共享
|
||||
- 任务管理(创建/更新/状态流转/进度追踪)
|
||||
- 里程碑管理
|
||||
- 任务问题追踪
|
||||
### 🏢 多租户架构
|
||||
- 工作空间隔离
|
||||
- 团队成员管理
|
||||
- 基于角色的权限控制(Owner/Admin/Member/Viewer)
|
||||
- 邀请和审批流程
|
||||
|
||||
### 📊 素材库管理
|
||||
- 素材库创建与管理
|
||||
- 素材上传、审核状态流转(pending_review → approved/rejected)
|
||||
- 素材诊断(就绪度评分、缺口分析、智能视图)
|
||||
### 💸 订阅管理
|
||||
- 3 级订阅计划(Free/Pro/Enterprise)
|
||||
- 配额管理(项目数/存储空间)
|
||||
- 升级和取消订阅
|
||||
|
||||
### ⚡ 高性能
|
||||
- 数据库连接池(5-6x 性能提升)
|
||||
- 请求日志和监控
|
||||
- 慢查询检测
|
||||
- 健康检查(Kubernetes 就绪)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 方式 1: Docker Compose(推荐)
|
||||
### 方式 1: Docker(推荐)
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git
|
||||
git clone https://github.com/your-org/xiaoxia-saas.git
|
||||
cd xiaoxia-saas
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
# 编辑 .env 填写数据库、Redis、OSS 等配置
|
||||
|
||||
# 3. 启动所有服务
|
||||
# 2. 启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
# 4. 访问 API 文档
|
||||
# 3. 访问 API 文档
|
||||
open http://localhost:8000/docs
|
||||
```
|
||||
|
||||
就这么简单!🎉
|
||||
|
||||
### 方式 2: 本地开发
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git
|
||||
git clone https://github.com/your-org/xiaoxia-saas.git
|
||||
cd xiaoxia-saas
|
||||
|
||||
# 2. 创建虚拟环境
|
||||
@@ -72,90 +74,36 @@ source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
# 3. 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 4. 配置环境变量
|
||||
cp .env.example .env
|
||||
|
||||
# 5. 启动 API 服务
|
||||
# 4. 启动开发服务器
|
||||
uvicorn apps.api.main:app --reload
|
||||
|
||||
# 6. 访问 API 文档
|
||||
# 5. 访问 API 文档
|
||||
open http://localhost:8000/docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 API 文档
|
||||
## 📚 主要文档
|
||||
|
||||
### 交互式文档
|
||||
- **Swagger UI**: https://saas-api.xiaoxiajianji.com/docs
|
||||
- **OpenAPI Schema**: https://saas-api.xiaoxiajianji.com/openapi.json
|
||||
### 快速入口
|
||||
- **[API 主线清单](docs/API-MAINLINE.md)** - 所有 API endpoints 总览
|
||||
- **[代码状态标注](docs/CODE-STATUS.md)** - 代码库导航指南
|
||||
- **[API 使用指南](docs/API-GUIDE.md)** - API 详细用法
|
||||
|
||||
### 核心 API 路径
|
||||
### Phase 7 交付
|
||||
- **[Phase 7 进度](docs/PHASE7-PROGRESS.md)** - 核心视频剪辑业务已完成
|
||||
- **[Phase 7 设计](docs/PHASE7-DESIGN.md)** - 设计文档
|
||||
|
||||
**认证** (`/api/v1/auth`)
|
||||
### 基础设施
|
||||
- **[Docker 部署指南](docs/DOCKER-DEPLOYMENT.md)**
|
||||
- **[CI/CD 文档](docs/CI-CD.md)**
|
||||
- **[性能监控指南](docs/PERFORMANCE-MONITORING.md)**
|
||||
- **[健康检查指南](docs/HEALTH-CHECKS.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)
|
||||
### 开发规范
|
||||
- **[Git 工作流](docs/GIT-WORKFLOW.md)**
|
||||
- **[环境配置指南](docs/ENVIRONMENT-CONFIG.md)**
|
||||
- **[贡献指南](CONTRIBUTING.md)**
|
||||
|
||||
---
|
||||
|
||||
@@ -163,49 +111,61 @@ AI 分类 → POST /api/v1/classification-jobs
|
||||
|
||||
```
|
||||
小虾 SaaS
|
||||
├── packages/ # 核心业务逻辑(Clean Architecture)
|
||||
│ ├── domain/ # 领域模型(dataclass)
|
||||
│ ├── application/ # 用例(Use Cases)
|
||||
│ ├── ports/ # 接口定义(抽象端口)
|
||||
│ └── adapters/ # 适配器实现(SQLAlchemy、Redis、SMTP 等)
|
||||
├── packages/ # 核心业务逻辑
|
||||
│ ├── domain/ # 领域模型
|
||||
│ ├── application/ # 用例
|
||||
│ ├── ports/ # 接口定义
|
||||
│ └── adapters/ # 适配器实现
|
||||
├── apps/ # 应用层
|
||||
│ ├── api/ # FastAPI 应用 + 路由 + Pydantic schemas
|
||||
│ ├── web/ # React + Vite 前端
|
||||
│ └── worker/ # Celery 异步任务(视频处理、分类等)
|
||||
├── migrations/ # Alembic 数据库迁移
|
||||
│ ├── api/ # FastAPI 应用
|
||||
│ ├── web/ # React 前端
|
||||
│ └── worker/ # Celery worker
|
||||
├── migrations/ # 数据库迁移
|
||||
├── tests/ # 测试
|
||||
│ ├── unit/ # 单元测试
|
||||
│ ├── integration/ # 集成测试
|
||||
│ └── e2e/ # 端到端测试
|
||||
│ └── integration/ # 集成测试
|
||||
└── docs/ # 文档
|
||||
```
|
||||
|
||||
**设计模式:**
|
||||
- Clean Architecture(依赖方向:外层 → 内层)
|
||||
- 依赖注入(FastAPI Depends)
|
||||
- Repository 模式(通过 ports 抽象)
|
||||
- Clean Architecture
|
||||
- 依赖注入
|
||||
- Repository 模式
|
||||
- Domain-Driven Design
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
## 🔑 核心 API 路径
|
||||
|
||||
**后端:**
|
||||
- Python 3.12 + FastAPI 0.115.0
|
||||
- PostgreSQL 16(生产)
|
||||
- Redis 7(缓存 + Celery Broker)
|
||||
- Celery(异步任务:视频处理、素材导入、分类)
|
||||
- 阿里云 OSS(文件存储)
|
||||
### 视频剪辑主链路(Phase 7)
|
||||
|
||||
**前端:**
|
||||
- React 18 + TypeScript
|
||||
- Vite(构建工具)
|
||||
- Ant Design(UI 组件)
|
||||
```
|
||||
上传素材
|
||||
↓
|
||||
POST /api/v1/upload
|
||||
|
||||
**部署:**
|
||||
- Docker + Docker Compose
|
||||
- Gitea + Gitea Actions(CI/CD)
|
||||
- Nginx(反向代理)
|
||||
创建 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)
|
||||
|
||||
---
|
||||
|
||||
@@ -225,32 +185,51 @@ 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) | ✅ 已完成 |
|
||||
| 分片上传(最大 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)
|
||||
| Phase 7(视频剪辑主链) | ✅ 已完成 |
|
||||
| CI/CD 稳定性 | ✅ 已修复 |
|
||||
| 认证系统 | ✅ 完整 |
|
||||
| 多租户架构 | ✅ 完整 |
|
||||
| 订阅系统 | ✅ 完整 |
|
||||
| 前端联调 | ✅ 完成 |
|
||||
|
||||
---
|
||||
|
||||
@@ -258,8 +237,38 @@ pytest --cov=packages --cov-report=html
|
||||
|
||||
欢迎贡献!请查看 [贡献指南](CONTRIBUTING.md)
|
||||
|
||||
**仓库地址**: https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
**License**: MIT
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情
|
||||
|
||||
---
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
- **问题反馈:** GitHub Issues
|
||||
- **文档:** 参考 `docs/` 目录
|
||||
|
||||
---
|
||||
|
||||
## 🎉 致谢
|
||||
|
||||
感谢所有贡献者和使用者!
|
||||
|
||||
**开发团队:** 小虾 🦐
|
||||
|
||||
---
|
||||
|
||||
**⭐ 如果这个项目对你有帮助,请给一个 Star!**
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-06-19
|
||||
**当前版本**: Phase 7 完成
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Add project titles.
|
||||
|
||||
Revision ID: 003
|
||||
Revises: 002
|
||||
Create Date: 2026-06-24
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "003"
|
||||
down_revision: Union[str, None] = "002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"project_titles",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("text", sa.String(length=200), nullable=False),
|
||||
sa.Column("category", sa.String(length=50), nullable=False, server_default="default"),
|
||||
sa.Column("usage_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("created_by_user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_project_titles_project_id"), "project_titles", ["project_id"], unique=False)
|
||||
op.create_index(op.f("ix_project_titles_workspace_id"), "project_titles", ["workspace_id"], unique=False)
|
||||
op.create_index(op.f("ix_project_titles_category"), "project_titles", ["category"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_project_titles_category"), table_name="project_titles")
|
||||
op.drop_index(op.f("ix_project_titles_workspace_id"), table_name="project_titles")
|
||||
op.drop_index(op.f("ix_project_titles_project_id"), table_name="project_titles")
|
||||
op.drop_table("project_titles")
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Add project title favorite flag.
|
||||
|
||||
Revision ID: 004
|
||||
Revises: 003
|
||||
Create Date: 2026-06-24
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "004"
|
||||
down_revision: Union[str, None] = "003"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("project_titles", sa.Column("favorite", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
op.create_index(op.f("ix_project_titles_favorite"), "project_titles", ["favorite"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_project_titles_favorite"), table_name="project_titles")
|
||||
op.drop_column("project_titles", "favorite")
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Add generated video management fields.
|
||||
|
||||
Revision ID: 005
|
||||
Revises: 004
|
||||
Create Date: 2026-06-24
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "005"
|
||||
down_revision: Union[str, None] = "004"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generated_videos", sa.Column("status", sa.String(length=20), nullable=False, server_default="completed")
|
||||
)
|
||||
op.add_column(
|
||||
"generated_videos",
|
||||
sa.Column("review_status", sa.String(length=20), nullable=False, server_default="pending_review"),
|
||||
)
|
||||
op.add_column("generated_videos", sa.Column("generation_params", sa.Text(), nullable=False, server_default="{}"))
|
||||
op.add_column("generated_videos", sa.Column("updated_at", sa.DateTime(), nullable=True))
|
||||
op.create_index(op.f("ix_generated_videos_status"), "generated_videos", ["status"], unique=False)
|
||||
op.create_index(op.f("ix_generated_videos_review_status"), "generated_videos", ["review_status"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_generated_videos_review_status"), table_name="generated_videos")
|
||||
op.drop_index(op.f("ix_generated_videos_status"), table_name="generated_videos")
|
||||
op.drop_column("generated_videos", "updated_at")
|
||||
op.drop_column("generated_videos", "generation_params")
|
||||
op.drop_column("generated_videos", "review_status")
|
||||
op.drop_column("generated_videos", "status")
|
||||
@@ -1,59 +0,0 @@
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "006"
|
||||
down_revision = "005"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"edit_templates",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("workspace_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("project_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("name", sa.String(120), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("target_duration", sa.Float(), nullable=False, server_default="30"),
|
||||
sa.Column("clip_count", sa.Integer(), nullable=False, server_default="3"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default=""),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_table(
|
||||
"edit_plans",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("workspace_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("project_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("template_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("asset_library_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("title_id", sa.String(32), nullable=False, server_default=""),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="draft", index=True),
|
||||
sa.Column("summary", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default=""),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_table(
|
||||
"edit_plan_clips",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("edit_plan_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("asset_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("start_time", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("duration", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("reason", sa.Text(), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column("generation_tasks", sa.Column("edit_plan_id", sa.String(32), nullable=False, server_default=""))
|
||||
op.create_index("ix_generation_tasks_edit_plan_id", "generation_tasks", ["edit_plan_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_generation_tasks_edit_plan_id", table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "edit_plan_id")
|
||||
op.drop_table("edit_plan_clips")
|
||||
op.drop_table("edit_plans")
|
||||
op.drop_table("edit_templates")
|
||||
@@ -1,28 +0,0 @@
|
||||
"""Add editing_mode to generation_tasks
|
||||
|
||||
Revision ID: 007
|
||||
Revises: 006
|
||||
Create Date: 2026-06-26
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = '007'
|
||||
down_revision = '006'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'generation_tasks',
|
||||
sa.Column('editing_mode', sa.String(20), nullable=False, server_default='one_take')
|
||||
)
|
||||
# 添加索引以支持查询
|
||||
op.create_index('ix_generation_tasks_editing_mode', 'generation_tasks', ['editing_mode'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_generation_tasks_editing_mode', table_name='generation_tasks')
|
||||
op.drop_column('generation_tasks', 'editing_mode')
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Add video fingerprint and duplicate detection fields to generated_videos table.
|
||||
|
||||
Revision ID: 008
|
||||
Revises: 007
|
||||
Create Date: 2024-06-26
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "008"
|
||||
down_revision = "007"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add video_fingerprint column as JSON text
|
||||
op.add_column(
|
||||
"generated_videos",
|
||||
sa.Column("video_fingerprint", sa.Text(), nullable=True)
|
||||
)
|
||||
# Add is_duplicate column
|
||||
op.add_column(
|
||||
"generated_videos",
|
||||
sa.Column("is_duplicate", sa.Boolean(), nullable=False, server_default="false")
|
||||
)
|
||||
# Add duplicate_of column for tracking original video
|
||||
op.add_column(
|
||||
"generated_videos",
|
||||
sa.Column("duplicate_of", sa.String(32), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generated_videos", "duplicate_of")
|
||||
op.drop_column("generated_videos", "is_duplicate")
|
||||
op.drop_column("generated_videos", "video_fingerprint")
|
||||
@@ -1,203 +0,0 @@
|
||||
"""Remove workspace concept - Projects now directly under User
|
||||
|
||||
Revision ID: 009
|
||||
Revises: 008
|
||||
Create Date: 2026-06-26
|
||||
|
||||
This migration:
|
||||
1. Moves subscription/quota fields from workspaces to users table
|
||||
2. Converts projects.workspace_id to projects.owner_user_id
|
||||
3. Adds shared_users JSON field to projects table
|
||||
4. Removes workspace_id from all tables that had it
|
||||
5. Drops workspace-related tables: workspaces, workspace_members, workspace_invitations
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
# revision identifiers
|
||||
revision = "009"
|
||||
down_revision = "008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Step 1: Add subscription/quota fields to users table
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS subscription_plan VARCHAR(20) NOT NULL DEFAULT free
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(20) NOT NULL DEFAULT active
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS subscription_expires_at TIMESTAMP
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS max_projects FLOAT NOT NULL DEFAULT 3
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS max_storage_gb FLOAT NOT NULL DEFAULT 10
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS used_storage_gb FLOAT NOT NULL DEFAULT 0
|
||||
"""))
|
||||
|
||||
# Step 2: Copy subscription data from workspaces to users
|
||||
conn.execute(text("""
|
||||
UPDATE users SET
|
||||
subscription_plan = w.subscription_plan,
|
||||
subscription_status = w.subscription_status,
|
||||
subscription_expires_at = w.subscription_expires_at,
|
||||
max_projects = w.max_projects,
|
||||
max_storage_gb = w.max_storage_gb,
|
||||
used_storage_gb = w.used_storage_gb
|
||||
FROM workspaces w
|
||||
WHERE w.owner_user_id = users.id
|
||||
"""))
|
||||
|
||||
# Step 3: Add owner_user_id and shared_users to projects table
|
||||
conn.execute(text("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS owner_user_id VARCHAR(32)
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS shared_users JSON
|
||||
"""))
|
||||
|
||||
# Step 4: Migrate workspace_id to owner_user_id (from workspace_members where role=owner)
|
||||
conn.execute(text("""
|
||||
UPDATE projects SET
|
||||
owner_user_id = wm.user_id
|
||||
FROM workspace_members wm
|
||||
WHERE wm.workspace_id = projects.workspace_id
|
||||
AND wm.role = 'owner'
|
||||
"""))
|
||||
|
||||
# Set shared_users to empty array for all projects
|
||||
conn.execute(text("""
|
||||
UPDATE projects SET shared_users = '[]'::json
|
||||
WHERE shared_users IS NULL
|
||||
"""))
|
||||
|
||||
# Step 5: Remove workspace_id from all tables
|
||||
tables_with_workspace_id = [
|
||||
"asset_libraries",
|
||||
"assets",
|
||||
"classification_jobs",
|
||||
"edit_plans",
|
||||
"edit_templates",
|
||||
"generation_tasks",
|
||||
"generated_videos",
|
||||
"ingest_jobs",
|
||||
"milestones",
|
||||
"project_titles",
|
||||
"tasks",
|
||||
"task_issues",
|
||||
]
|
||||
|
||||
for table in tables_with_workspace_id:
|
||||
conn.execute(text(f"""
|
||||
ALTER TABLE {table} DROP COLUMN IF EXISTS workspace_id
|
||||
"""))
|
||||
|
||||
# Step 6: Drop workspace-related tables
|
||||
conn.execute(text("""
|
||||
DROP TABLE IF EXISTS workspace_invitations
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
DROP TABLE IF EXISTS workspace_members
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
DROP TABLE IF EXISTS workspaces
|
||||
"""))
|
||||
|
||||
# Step 7: Drop workspace_id from projects table
|
||||
conn.execute(text("""
|
||||
ALTER TABLE projects DROP COLUMN IF EXISTS workspace_id
|
||||
"""))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Add back workspace tables (simplified - in real scenario would need full recreation)
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
owner_user_id VARCHAR(36) NOT NULL,
|
||||
subscription_plan VARCHAR(20) NOT NULL DEFAULT free,
|
||||
subscription_status VARCHAR(20) NOT NULL DEFAULT active,
|
||||
subscription_expires_at TIMESTAMP,
|
||||
max_projects FLOAT NOT NULL DEFAULT 3,
|
||||
max_storage_gb FLOAT NOT NULL DEFAULT 10,
|
||||
used_storage_gb FLOAT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS workspace_members (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
workspace_id VARCHAR(36) NOT NULL,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
invited_by VARCHAR(36),
|
||||
joined_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(workspace_id, user_id)
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS workspace_invitations (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
workspace_id VARCHAR(36) NOT NULL,
|
||||
inviter_user_id VARCHAR(36) NOT NULL,
|
||||
invitee_email VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
invitation_token VARCHAR(255) NOT NULL UNIQUE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT pending,
|
||||
expires_at TIMESTAMP,
|
||||
accepted_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
# Add back workspace_id column to projects
|
||||
conn.execute(text("""
|
||||
ALTER TABLE projects ADD COLUMN workspace_id VARCHAR(32)
|
||||
"""))
|
||||
|
||||
# Add back workspace_id columns to other tables
|
||||
tables_with_workspace_id = [
|
||||
"asset_libraries",
|
||||
"assets",
|
||||
"classification_jobs",
|
||||
"edit_plans",
|
||||
"edit_templates",
|
||||
"generation_tasks",
|
||||
"generated_videos",
|
||||
"ingest_jobs",
|
||||
"milestones",
|
||||
"project_titles",
|
||||
"tasks",
|
||||
"task_issues",
|
||||
]
|
||||
|
||||
for table in tables_with_workspace_id:
|
||||
conn.execute(text(f"""
|
||||
ALTER TABLE {table} ADD COLUMN workspace_id VARCHAR(36)
|
||||
"""))
|
||||
|
||||
# Note: This downgrade is incomplete - projects.owner_user_id data would need to be
|
||||
# converted back to workspace_ids, which requires reconstructing workspace records.
|
||||
@@ -1,117 +0,0 @@
|
||||
"""Phase 0 - 扩展性基础设施:metadata JSONB + title_libraries + voice_libraries
|
||||
|
||||
Revision ID: 010
|
||||
Revises: 009
|
||||
Create Date: 2026-06-28
|
||||
|
||||
This migration:
|
||||
1. Adds metadata JSONB column to 5 tables:
|
||||
- projects, asset_libraries, assets, edit_templates, generation_tasks
|
||||
2. Creates title_libraries table (独立标题库,支持跨项目复用)
|
||||
3. Creates voice_libraries table (配音库,支持 AI 配音管理)
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = "010"
|
||||
down_revision = "009"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1. Add metadata JSONB to existing tables ──
|
||||
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE asset_libraries ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE assets ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE edit_templates ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE generation_tasks ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"
|
||||
))
|
||||
|
||||
# ── 2. Create title_libraries table ──
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS title_libraries (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
category VARCHAR(50) NOT NULL DEFAULT 'default',
|
||||
text VARCHAR(500) NOT NULL,
|
||||
tags JSONB NOT NULL DEFAULT '[]',
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_title_libraries_user_id ON title_libraries(user_id)"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_title_libraries_category ON title_libraries(category)"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_title_libraries_is_active ON title_libraries(is_active)"
|
||||
))
|
||||
|
||||
# ── 3. Create voice_libraries table ──
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS voice_libraries (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
project_id VARCHAR(36),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
voice_provider VARCHAR(50) NOT NULL DEFAULT '',
|
||||
voice_id VARCHAR(100) NOT NULL DEFAULT '',
|
||||
voice_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
audio_url VARCHAR(1000) NOT NULL DEFAULT '',
|
||||
duration FLOAT NOT NULL DEFAULT 0,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'completed',
|
||||
tags JSONB NOT NULL DEFAULT '[]',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_voice_libraries_user_id ON voice_libraries(user_id)"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_voice_libraries_project_id ON voice_libraries(project_id)"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_voice_libraries_status ON voice_libraries(status)"
|
||||
))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Drop new tables
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS voice_libraries"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS title_libraries"))
|
||||
|
||||
# Remove metadata columns
|
||||
conn.execute(sa.text("ALTER TABLE generation_tasks DROP COLUMN IF EXISTS metadata"))
|
||||
conn.execute(sa.text("ALTER TABLE edit_templates DROP COLUMN IF EXISTS metadata"))
|
||||
conn.execute(sa.text("ALTER TABLE assets DROP COLUMN IF EXISTS metadata"))
|
||||
conn.execute(sa.text("ALTER TABLE asset_libraries DROP COLUMN IF EXISTS metadata"))
|
||||
conn.execute(sa.text("ALTER TABLE projects DROP COLUMN IF EXISTS metadata"))
|
||||
@@ -1,144 +0,0 @@
|
||||
"""Phase 1 - 核心重构:清理废弃表
|
||||
|
||||
Revision ID: 011
|
||||
Revises: 010
|
||||
Create Date: 2026-06-28
|
||||
|
||||
This migration:
|
||||
1. Drops 6 deprecated tables:
|
||||
- tasks (任务管理)
|
||||
- milestones (里程碑)
|
||||
- task_issues (任务问题)
|
||||
- project_titles (项目标题,已被 title_libraries 替代)
|
||||
- edit_plans (编辑计划)
|
||||
- edit_plan_clips (编辑计划片段)
|
||||
2. Removes edit_plan_id column from generation_tasks table
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = "011"
|
||||
down_revision = "010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1. Drop deprecated tables ──
|
||||
|
||||
# Drop in reverse dependency order
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS task_issues"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS milestones"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS tasks"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS project_titles"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS edit_plan_clips"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS edit_plans"))
|
||||
|
||||
# ── 2. Remove edit_plan_id from generation_tasks ──
|
||||
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE generation_tasks DROP COLUMN IF EXISTS edit_plan_id"
|
||||
))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1. Re-add edit_plan_id to generation_tasks ──
|
||||
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE generation_tasks ADD COLUMN IF NOT EXISTS edit_plan_id VARCHAR(32)"
|
||||
))
|
||||
|
||||
# ── 2. Recreate deprecated tables (basic structure) ──
|
||||
|
||||
# Note: Full schema recreation is complex; this is a minimal downgrade
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS edit_plans (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
project_id VARCHAR(32) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft',
|
||||
created_by_user_id VARCHAR(32) NOT NULL DEFAULT '',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS edit_plan_clips (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
edit_plan_id VARCHAR(32) NOT NULL,
|
||||
asset_id VARCHAR(32) NOT NULL,
|
||||
order_index INTEGER NOT NULL DEFAULT 0,
|
||||
start_time FLOAT NOT NULL DEFAULT 0,
|
||||
end_time FLOAT NOT NULL DEFAULT 0,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS project_titles (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
project_id VARCHAR(36) NOT NULL,
|
||||
text VARCHAR(500) NOT NULL,
|
||||
category VARCHAR(50) NOT NULL DEFAULT 'default',
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||
tags JSONB NOT NULL DEFAULT '[]',
|
||||
favorite BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
project_id VARCHAR(32) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
priority VARCHAR(20) NOT NULL DEFAULT 'medium',
|
||||
assigned_to_user_id VARCHAR(32) NOT NULL DEFAULT '',
|
||||
due_date TIMESTAMP,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS milestones (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
project_id VARCHAR(32) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
due_date TIMESTAMP,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS task_issues (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
task_id VARCHAR(32) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||
priority VARCHAR(20) NOT NULL DEFAULT 'medium',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Phase 2 - 查重功能:duplication_records + duplication_segments
|
||||
|
||||
Revision ID: 012
|
||||
Revises: 011
|
||||
Create Date: 2026-06-28
|
||||
|
||||
This migration creates two new tables:
|
||||
1. duplication_records — 查重记录主表
|
||||
2. duplication_segments — 重复片段详情表
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = "012"
|
||||
down_revision = "011"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1. Create duplication_records table ──
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS duplication_records (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
filename VARCHAR(500) NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
storage_key VARCHAR(500) NOT NULL,
|
||||
duration_seconds FLOAT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
duplicate_rate FLOAT,
|
||||
duplicate_count INTEGER NOT NULL DEFAULT 0,
|
||||
video_fingerprint TEXT,
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_duplication_records_user_id ON duplication_records(user_id)"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_duplication_records_status ON duplication_records(status)"
|
||||
))
|
||||
|
||||
# ── 2. Create duplication_segments table ──
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS duplication_segments (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
record_id VARCHAR(36) NOT NULL,
|
||||
source_start FLOAT NOT NULL,
|
||||
source_end FLOAT NOT NULL,
|
||||
matched_video_id VARCHAR(36) NOT NULL,
|
||||
matched_video_name VARCHAR(500) NOT NULL DEFAULT '',
|
||||
matched_start FLOAT NOT NULL,
|
||||
matched_end FLOAT NOT NULL,
|
||||
similarity FLOAT NOT NULL
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_duplication_segments_record_id ON duplication_segments(record_id)"
|
||||
))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS duplication_segments"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS duplication_records"))
|
||||
+18
-47
@@ -1,20 +1,15 @@
|
||||
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
|
||||
from app.api.routes.asset_libraries import router as asset_libraries_router
|
||||
from app.api.routes.assets import router as assets_router
|
||||
from app.api.routes.auth import router as auth_router
|
||||
from app.api.routes.chunked_upload import router as chunked_upload_router
|
||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||
from app.api.routes.duplication import router as duplication_router
|
||||
from app.api.routes.generated_videos import router as generated_videos_router
|
||||
from app.api.routes.subscription import router as subscription_router
|
||||
from app.api.routes.titles import router as titles_router
|
||||
from app.api.routes.voices import router as voices_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.task_center import router as task_center_router
|
||||
from app.api.routes.upload import router as upload_router
|
||||
from app.api.routes.workspaces import router as workspaces_router
|
||||
from fastapi import APIRouter
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
@@ -23,78 +18,54 @@ health_router.include_router(health_check_router)
|
||||
|
||||
api_router.include_router(
|
||||
auth_router,
|
||||
tags=["Auth"],
|
||||
tags=["认证"],
|
||||
)
|
||||
api_router.include_router(
|
||||
workspaces_router,
|
||||
tags=["工作空间"],
|
||||
)
|
||||
api_router.include_router(
|
||||
projects_router,
|
||||
prefix="/projects",
|
||||
tags=["Project"],
|
||||
)
|
||||
api_router.include_router(
|
||||
task_center_router,
|
||||
tags=["TaskCenter"],
|
||||
)
|
||||
api_router.include_router(
|
||||
asset_diagnosis_router,
|
||||
tags=["AssetDiagnosis"],
|
||||
tags=["项目管理"],
|
||||
)
|
||||
api_router.include_router(
|
||||
asset_libraries_router,
|
||||
prefix="/asset-libraries",
|
||||
tags=["AssetLibrary"],
|
||||
tags=["素材库管理"],
|
||||
)
|
||||
api_router.include_router(
|
||||
assets_router,
|
||||
prefix="/assets",
|
||||
tags=["Asset"],
|
||||
tags=["素材资产"],
|
||||
)
|
||||
api_router.include_router(
|
||||
ingest_jobs_router,
|
||||
prefix="/ingest-jobs",
|
||||
tags=["IngestJob"],
|
||||
tags=["导入任务"],
|
||||
)
|
||||
api_router.include_router(
|
||||
classification_jobs_router,
|
||||
prefix="/classification-jobs",
|
||||
tags=["ClassificationJob"],
|
||||
tags=["分类任务"],
|
||||
)
|
||||
api_router.include_router(
|
||||
upload_router,
|
||||
prefix="/upload",
|
||||
tags=["Upload"],
|
||||
)
|
||||
api_router.include_router(
|
||||
chunked_upload_router,
|
||||
prefix="/upload/chunk",
|
||||
tags=["ChunkedUpload"],
|
||||
tags=["文件上传"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generation_tasks_router,
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
tags=["生成任务"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generated_videos_router,
|
||||
prefix="/generated-videos",
|
||||
tags=["GeneratedVideo"],
|
||||
tags=["成片管理"],
|
||||
)
|
||||
api_router.include_router(
|
||||
titles_router,
|
||||
prefix="/titles",
|
||||
tags=["TitleLibrary"],
|
||||
)
|
||||
api_router.include_router(
|
||||
voices_router,
|
||||
prefix="/voices",
|
||||
tags=["VoiceLibrary"],
|
||||
)
|
||||
api_router.include_router(
|
||||
duplication_router,
|
||||
prefix="/duplication",
|
||||
tags=["Duplication"],
|
||||
)
|
||||
api_router.include_router(
|
||||
subscription_router,
|
||||
prefix="/subscription",
|
||||
tags=["Subscription"],
|
||||
project_management_router,
|
||||
prefix="/project-management",
|
||||
tags=["项目推进管理"],
|
||||
)
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
from app.schemas.asset_diagnosis import AssetGapItem, AssetSmartViewItem, ProjectAssetDiagnosisResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.domain import Asset, AssetLibraryKind, AssetStatus
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _asset_kind(asset: Asset) -> str:
|
||||
if asset.mime_type.startswith("video"):
|
||||
return "video"
|
||||
if asset.mime_type.startswith("audio"):
|
||||
return "voice"
|
||||
if asset.mime_type.startswith("image"):
|
||||
return "image"
|
||||
return asset.mime_type.split("/", 1)[0]
|
||||
|
||||
|
||||
def _readiness_label(score: int) -> str:
|
||||
if score >= 80:
|
||||
return "素材充足"
|
||||
if score >= 60:
|
||||
return "基本可生成"
|
||||
if score >= 40:
|
||||
return "需要补素材"
|
||||
return "暂不建议生成"
|
||||
|
||||
|
||||
def _build_diagnosis(project_id: str, assets: list[Asset]) -> ProjectAssetDiagnosisResponse:
|
||||
ready_assets = [asset for asset in assets if asset.status == AssetStatus.READY]
|
||||
video_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.VIDEO]
|
||||
image_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.IMAGE]
|
||||
voice_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.VOICE]
|
||||
problem_assets = [
|
||||
asset for asset in assets if asset.status in {AssetStatus.ERROR, AssetStatus.UPLOADING, AssetStatus.PROCESSING}
|
||||
]
|
||||
unclassified_assets = [
|
||||
asset for asset in ready_assets if asset.classification_status.value in {"pending", "failed"}
|
||||
]
|
||||
risky_assets = [
|
||||
asset
|
||||
for asset in ready_assets
|
||||
if (asset.quality_score is not None and asset.quality_score < 60)
|
||||
or asset.metadata.get("review_status") == "rejected"
|
||||
or asset.status == AssetStatus.ERROR
|
||||
]
|
||||
used_assets = [asset for asset in ready_assets if int(asset.metadata.get("generation_use_count") or 0) > 0]
|
||||
unused_assets = [asset for asset in ready_assets if int(asset.metadata.get("generation_use_count") or 0) == 0]
|
||||
pending_review_assets = [asset for asset in ready_assets if asset.metadata.get("review_status") == "pending_review"]
|
||||
total_duration = round(sum(float(asset.duration or 0) for asset in video_assets), 2)
|
||||
estimated_video_count = max(
|
||||
0, min(len(video_assets), int(total_duration // 5) if total_duration else len(video_assets))
|
||||
)
|
||||
|
||||
score = 20
|
||||
if video_assets:
|
||||
score += 30
|
||||
if len(video_assets) >= 3:
|
||||
score += 15
|
||||
if total_duration >= 15:
|
||||
score += 15
|
||||
if image_assets:
|
||||
score += 5
|
||||
if voice_assets:
|
||||
score += 5
|
||||
if not problem_assets:
|
||||
score += 10
|
||||
score = max(0, min(100, score - min(25, len(risky_assets) * 5)))
|
||||
|
||||
gaps: list[AssetGapItem] = []
|
||||
if not video_assets:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="missing_video",
|
||||
severity="critical",
|
||||
message="缺少可用于生成的视频素材",
|
||||
recommendation="至少上传 1 个已导入完成的视频素材;建议上传 3 个以上,生成效果更稳定。",
|
||||
)
|
||||
)
|
||||
elif len(video_assets) < 3:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="low_video_count",
|
||||
severity="warning",
|
||||
message="视频素材数量偏少",
|
||||
recommendation="建议补充到 3 个以上视频素材,方便生成更多候选成片。",
|
||||
)
|
||||
)
|
||||
if total_duration and total_duration < 15:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="short_video_duration",
|
||||
severity="warning",
|
||||
message="可用视频总时长偏短",
|
||||
recommendation="建议补充更多原始视频,至少达到 15 秒以上。",
|
||||
)
|
||||
)
|
||||
if not voice_assets:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="missing_voice",
|
||||
severity="info",
|
||||
message="暂未配置配音素材",
|
||||
recommendation="如果本项目需要口播/旁白,请上传配音素材;纯画面生成可暂时忽略。",
|
||||
)
|
||||
)
|
||||
if problem_assets:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="not_ready_assets",
|
||||
severity="warning",
|
||||
message=f"有 {len(problem_assets)} 个素材尚未 ready",
|
||||
recommendation="等待导入完成或删除失败素材后再生成。",
|
||||
)
|
||||
)
|
||||
if risky_assets:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="low_quality_assets",
|
||||
severity="warning",
|
||||
message=f"有 {len(risky_assets)} 个素材质量分偏低",
|
||||
recommendation="优先使用清晰、稳定、时长充足的视频素材。",
|
||||
)
|
||||
)
|
||||
|
||||
smart_views = [
|
||||
AssetSmartViewItem(
|
||||
key="recommended", label="推荐素材", count=len(video_assets), description="已导入完成、可参与生成的视频素材"
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="needs_attention",
|
||||
label="慎用素材",
|
||||
count=len(problem_assets) + len(risky_assets),
|
||||
description="导入未完成、失败或质量分偏低的素材",
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="high_risk", label="高风险素材", count=len(risky_assets), description="质量分偏低或复核拒绝的素材"
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="unclassified",
|
||||
label="未分类素材",
|
||||
count=len(unclassified_assets),
|
||||
description="尚未完成分类或分类失败的 ready 素材",
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="recent",
|
||||
label="最近上传",
|
||||
count=min(len(assets), 10),
|
||||
description="最近进入素材库的素材,可用于快速复核",
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="unused", label="未使用素材", count=len(unused_assets), description="尚未参与生成的 ready 素材"
|
||||
),
|
||||
AssetSmartViewItem(key="used", label="已使用素材", count=len(used_assets), description="已经参与过生成的素材"),
|
||||
AssetSmartViewItem(
|
||||
key="pending_review",
|
||||
label="待复核素材",
|
||||
count=len(pending_review_assets),
|
||||
description="生成后待人工复核的素材",
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="voice", label="配音素材", count=len(voice_assets), description="可用于后续配音/旁白工作流的素材"
|
||||
),
|
||||
]
|
||||
|
||||
return ProjectAssetDiagnosisResponse(
|
||||
project_id=project_id,
|
||||
readiness_score=score,
|
||||
readiness_label=_readiness_label(score),
|
||||
total_assets=len(assets),
|
||||
ready_assets=len(ready_assets),
|
||||
video_assets=len(video_assets),
|
||||
image_assets=len(image_assets),
|
||||
voice_assets=len(voice_assets),
|
||||
total_duration_seconds=total_duration,
|
||||
estimated_video_count=estimated_video_count,
|
||||
used_assets=len(used_assets),
|
||||
unused_assets=len(unused_assets),
|
||||
pending_review_assets=len(pending_review_assets),
|
||||
smart_views=smart_views,
|
||||
gaps=gaps,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/asset-diagnosis", response_model=ProjectAssetDiagnosisResponse)
|
||||
def get_project_asset_diagnosis(
|
||||
project_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> ProjectAssetDiagnosisResponse:
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
|
||||
libraries = asset_library_repository.list_by_project(project_id)
|
||||
assets: list[Asset] = []
|
||||
for library in libraries:
|
||||
assets.extend(asset_repository.list_by_library(library.id))
|
||||
|
||||
return _build_diagnosis(project_id, assets)
|
||||
@@ -1,13 +1,13 @@
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_asset_library_repository, get_project_repository
|
||||
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, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from packages.application import (
|
||||
CreateAssetLibraryCommand,
|
||||
@@ -16,22 +16,25 @@ from packages.application import (
|
||||
ListAssetLibrariesUseCase,
|
||||
)
|
||||
from packages.domain import AssetLibraryKind
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
||||
"""检查用户是否有项目访问权限"""
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
if not project.can_access(user_id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project")
|
||||
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,
|
||||
@@ -42,30 +45,20 @@ def _to_asset_library_response(item) -> AssetLibraryResponse:
|
||||
|
||||
@router.get("", response_model=ListAssetLibrariesResponse)
|
||||
def list_asset_libraries(
|
||||
project_id: str | None = Query(None),
|
||||
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:
|
||||
user_id = authenticated_user.user.id
|
||||
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)
|
||||
|
||||
if project_id:
|
||||
# If project_id provided, check access and filter by project
|
||||
project = GetProjectUseCase(project_repository).execute(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
if not project.can_access(user_id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project")
|
||||
items = use_case.execute(project_id)
|
||||
else:
|
||||
# If no project_id, list all libraries from accessible projects
|
||||
accessible_projects = project_repository.find_accessible_projects(user_id)
|
||||
all_items = []
|
||||
for proj in accessible_projects:
|
||||
all_items.extend(use_case.execute(proj.id))
|
||||
items = all_items
|
||||
|
||||
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])
|
||||
|
||||
|
||||
@@ -75,15 +68,16 @@ def create_asset_library(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> AssetLibraryResponse:
|
||||
project = project_repository.find_by_id(request.project_id)
|
||||
if project is None:
|
||||
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")
|
||||
if not project.can_access(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project")
|
||||
_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),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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, UpdateAssetReviewRequest
|
||||
from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
@@ -15,6 +17,7 @@ from packages.application import (
|
||||
ListAssetsUseCase,
|
||||
)
|
||||
from packages.domain import AssetStatus, ClassificationStatus
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -22,6 +25,7 @@ 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,
|
||||
@@ -42,57 +46,23 @@ def _to_asset_response(item) -> AssetResponse:
|
||||
)
|
||||
|
||||
|
||||
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
||||
"""检查用户是否有项目访问权限"""
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
if not project.can_access(user_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
|
||||
@router.get("", response_model=ListAssetsResponse)
|
||||
def list_assets(
|
||||
library_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_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")
|
||||
_check_project_access(library.project_id, authenticated_user.user.id, project_repository)
|
||||
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])
|
||||
|
||||
|
||||
def _apply_asset_review_status(item, review_status: str):
|
||||
item.metadata = {
|
||||
**item.metadata,
|
||||
"review_status": review_status,
|
||||
}
|
||||
return item
|
||||
|
||||
|
||||
@router.patch("/{asset_id}/review", response_model=AssetResponse)
|
||||
def update_asset_review_status(
|
||||
asset_id: str,
|
||||
request: UpdateAssetReviewRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AssetResponse:
|
||||
item = asset_repository.get(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
_apply_asset_review_status(item, request.review_status)
|
||||
updated = asset_repository.update(item)
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.post("", response_model=AssetResponse)
|
||||
def create_asset(
|
||||
request: CreateAssetRequest,
|
||||
@@ -100,20 +70,20 @@ def create_asset(
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> AssetResponse:
|
||||
require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository)
|
||||
project = project_repository.find_by_id(request.project_id)
|
||||
if project is None:
|
||||
if project is None or project.workspace_id != request.workspace_id:
|
||||
raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found")
|
||||
if not project.can_access(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
library = asset_library_repository.get(request.library_id)
|
||||
if library is None or library.project_id != request.project_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,
|
||||
|
||||
Executable → Regular
+4
-35
@@ -17,8 +17,6 @@ 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.login_use_case import RefreshTokenRequest as RefreshTokenUseCaseRequest
|
||||
from packages.application.auth.login_use_case import RefreshTokenUseCase
|
||||
from packages.application.auth.password_reset_use_case import RequestPasswordResetRequest as PasswordResetUseCaseRequest
|
||||
from packages.application.auth.password_reset_use_case import (
|
||||
RequestPasswordResetUseCase,
|
||||
@@ -52,12 +50,8 @@ class LoginRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user_id: str
|
||||
email: str
|
||||
@@ -99,7 +93,7 @@ async def register(
|
||||
):
|
||||
use_case = RegisterUserUseCase(
|
||||
user_repository=user_repository,
|
||||
base_url=settings.APP_BASE_URL,
|
||||
base_url="http://localhost:3000",
|
||||
email_service=email_service,
|
||||
)
|
||||
response, error = use_case.execute(
|
||||
@@ -139,7 +133,6 @@ async def login(
|
||||
|
||||
return LoginResponse(
|
||||
access_token=response.access_token,
|
||||
refresh_token=response.refresh_token,
|
||||
user_id=response.user_id,
|
||||
email=response.email,
|
||||
username=response.username,
|
||||
@@ -148,32 +141,6 @@ async def login(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh(
|
||||
request: RefreshRequest,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
session_store=Depends(get_auth_session_store),
|
||||
):
|
||||
use_case = RefreshTokenUseCase(
|
||||
user_repository=user_repository,
|
||||
session_store=session_store,
|
||||
)
|
||||
response, error = use_case.execute(RefreshTokenUseCaseRequest(refresh_token=request.refresh_token))
|
||||
if error or response is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token")
|
||||
|
||||
return LoginResponse(
|
||||
access_token=response.access_token,
|
||||
refresh_token=response.refresh_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:
|
||||
@@ -199,6 +166,7 @@ async def verify_email_post(
|
||||
|
||||
|
||||
@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),
|
||||
@@ -206,7 +174,7 @@ async def forgot_password(
|
||||
):
|
||||
success, error = RequestPasswordResetUseCase(
|
||||
user_repository=user_repository,
|
||||
base_url=settings.APP_BASE_URL,
|
||||
base_url="http://localhost:3000",
|
||||
email_service=email_service,
|
||||
).execute(PasswordResetUseCaseRequest(email=request.email))
|
||||
if not success:
|
||||
@@ -216,6 +184,7 @@ async def forgot_password(
|
||||
|
||||
|
||||
@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),
|
||||
|
||||
@@ -1,445 +0,0 @@
|
||||
"""
|
||||
Chunked upload routes for large file uploads (up to 2GB).
|
||||
Supports chunked upload, resume, and automatic cleanup of expired uploads.
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import get_settings
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_ingest_job_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
from app.schemas.chunked_upload import (
|
||||
ChunkedUploadCompleteRequest,
|
||||
ChunkedUploadCompleteResponse,
|
||||
ChunkedUploadInitRequest,
|
||||
ChunkedUploadInitResponse,
|
||||
ChunkedUploadStatusResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||
from fastapi.params import File
|
||||
|
||||
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration
|
||||
DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024 # 5MB
|
||||
MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024 # 2GB
|
||||
CHUNK_EXPIRY_HOURS = 24
|
||||
|
||||
# Allowed file types (consistent with existing upload.py)
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"image/jpeg", "image/png", "image/gif", "image/webp",
|
||||
"video/mp4", "video/quicktime", "video/x-msvideo", "video/webm",
|
||||
"audio/mpeg", "audio/wav", "audio/ogg", "audio/mp3",
|
||||
}
|
||||
|
||||
# Chunk storage root directory
|
||||
CHUNK_STORAGE_ROOT = Path("/tmp/chunked_uploads")
|
||||
|
||||
|
||||
def _get_chunk_dir(upload_id: str) -> Path:
|
||||
"""Get chunk storage directory"""
|
||||
return CHUNK_STORAGE_ROOT / upload_id
|
||||
|
||||
|
||||
def _get_upload_meta_path(upload_id: str) -> Path:
|
||||
"""Get upload metadata file path"""
|
||||
return CHUNK_STORAGE_ROOT / f"{upload_id}.meta.json"
|
||||
|
||||
|
||||
def _atomic_check_and_record(upload_id: str, chunk_index: int) -> bool:
|
||||
"""
|
||||
Atomically check if chunk is uploaded and record if not.
|
||||
Uses file locking to prevent race conditions.
|
||||
|
||||
Returns:
|
||||
True if chunk was newly recorded, False if already exists
|
||||
"""
|
||||
meta_path = _get_upload_meta_path(upload_id)
|
||||
CHUNK_STORAGE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(meta_path, "r+", encoding="utf-8") as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
meta = json.load(f)
|
||||
if chunk_index in meta["uploaded_chunks"]:
|
||||
return False
|
||||
meta["uploaded_chunks"].append(chunk_index)
|
||||
meta["status"] = "uploading"
|
||||
f.seek(0)
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||
f.truncate()
|
||||
return True
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _require_project_and_library(
|
||||
project_id: str,
|
||||
library_id: str,
|
||||
project_repository: Any,
|
||||
asset_library_repository: Any,
|
||||
) -> None:
|
||||
"""Verify project and asset library exist"""
|
||||
project = GetProjectUseCase(project_repository).execute(project_id)
|
||||
if project is None:
|
||||
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 for item in libraries):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
|
||||
|
||||
|
||||
def _load_upload_meta(upload_id: str) -> dict[str, Any]:
|
||||
"""Load upload metadata"""
|
||||
meta_path = _get_upload_meta_path(upload_id)
|
||||
if not meta_path.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
|
||||
|
||||
with open(meta_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _save_upload_meta(upload_id: str, meta: dict[str, Any]) -> None:
|
||||
"""Save upload metadata"""
|
||||
meta_path = _get_upload_meta_path(upload_id)
|
||||
CHUNK_STORAGE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _validate_file_type(content: bytes, filename: str) -> str:
|
||||
"""Validate file type"""
|
||||
try:
|
||||
import magic
|
||||
detected_mime = magic.from_buffer(content, mime=True)
|
||||
except ImportError:
|
||||
import mimetypes
|
||||
detected_mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
|
||||
if detected_mime not in ALLOWED_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported file type: {detected_mime}. Allowed types: {', '.join(sorted(ALLOWED_MIME_TYPES))}"
|
||||
)
|
||||
return detected_mime
|
||||
|
||||
|
||||
def _cleanup_expired_uploads() -> int:
|
||||
"""Cleanup expired uploads, returns number of cleaned uploads"""
|
||||
if not CHUNK_STORAGE_ROOT.exists():
|
||||
return 0
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
cleaned = 0
|
||||
|
||||
for meta_file in CHUNK_STORAGE_ROOT.glob("*.meta.json"):
|
||||
try:
|
||||
with open(meta_file, "r", encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
|
||||
expires_at = datetime.fromisoformat(meta["expires_at"])
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Only cleanup uploads that are not actively being uploaded
|
||||
if expires_at < now and meta.get("status") != "uploading":
|
||||
upload_id = meta["upload_id"]
|
||||
chunk_dir = _get_chunk_dir(upload_id)
|
||||
if chunk_dir.exists():
|
||||
shutil.rmtree(chunk_dir)
|
||||
meta_file.unlink()
|
||||
cleaned += 1
|
||||
logger.info(f"Cleaned up expired upload: {upload_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup upload metadata {meta_file}: {e}")
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
@router.post("/init", response_model=ChunkedUploadInitResponse)
|
||||
async def init_chunked_upload(
|
||||
request: ChunkedUploadInitRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
) -> ChunkedUploadInitResponse:
|
||||
"""Initialize chunked upload"""
|
||||
settings = get_settings()
|
||||
|
||||
# Validate file size
|
||||
if request.file_size > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"File exceeds maximum size ({MAX_FILE_SIZE // (1024 * 1024 * 1024)}GB)",
|
||||
)
|
||||
|
||||
# Validate project exists
|
||||
project = GetProjectUseCase(project_repository).execute(request.project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
# Verify asset library
|
||||
_require_project_and_library(
|
||||
request.project_id,
|
||||
request.library_id,
|
||||
project_repository,
|
||||
asset_library_repository,
|
||||
)
|
||||
|
||||
# Calculate chunk size
|
||||
chunk_size = DEFAULT_CHUNK_SIZE
|
||||
expected_chunks = (request.file_size + chunk_size - 1) // chunk_size
|
||||
if expected_chunks != request.total_chunks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"total_chunks mismatch. Expected {expected_chunks} for file size {request.file_size} with chunk size {chunk_size}",
|
||||
)
|
||||
|
||||
# Cleanup expired uploads
|
||||
_cleanup_expired_uploads()
|
||||
|
||||
# Generate upload ID
|
||||
upload_id = uuid4().hex
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(hours=CHUNK_EXPIRY_HOURS)
|
||||
|
||||
# Create chunk directory
|
||||
chunk_dir = _get_chunk_dir(upload_id)
|
||||
chunk_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save metadata
|
||||
meta = {
|
||||
"upload_id": upload_id,
|
||||
"filename": request.filename.replace("/", "_").replace("\\", "_"),
|
||||
"file_size": request.file_size,
|
||||
"total_chunks": request.total_chunks,
|
||||
"uploaded_chunks": [],
|
||||
"content_type": request.content_type,
|
||||
"project_id": request.project_id,
|
||||
"library_id": request.library_id,
|
||||
"status": "pending",
|
||||
"created_at": now.isoformat(),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
}
|
||||
_save_upload_meta(upload_id, meta)
|
||||
|
||||
return ChunkedUploadInitResponse(
|
||||
upload_id=upload_id,
|
||||
chunk_size=chunk_size,
|
||||
total_chunks=request.total_chunks,
|
||||
filename=meta["filename"],
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{upload_id}/{chunk_index}")
|
||||
async def upload_chunk(
|
||||
upload_id: str,
|
||||
chunk_index: int,
|
||||
chunk: UploadFile = File(..., description="Chunk data"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Upload a single chunk"""
|
||||
# Load metadata
|
||||
meta = _load_upload_meta(upload_id)
|
||||
|
||||
# Check expiry
|
||||
expires_at = datetime.fromisoformat(meta["expires_at"])
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
if expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Upload has expired")
|
||||
|
||||
# Validate chunk index
|
||||
if chunk_index < 0 or chunk_index >= meta["total_chunks"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid chunk index. Must be between 0 and {meta['total_chunks'] - 1}",
|
||||
)
|
||||
|
||||
# Atomic check and record to prevent race conditions
|
||||
if not _atomic_check_and_record(upload_id, chunk_index):
|
||||
return {"message": "Chunk already uploaded", "chunk_index": chunk_index}
|
||||
|
||||
# Read chunk data
|
||||
chunk_data = await chunk.read()
|
||||
|
||||
# Validate chunk size (last chunk can be smaller than chunk_size)
|
||||
expected_size = DEFAULT_CHUNK_SIZE
|
||||
if chunk_index == meta["total_chunks"] - 1:
|
||||
expected_size = meta["file_size"] - (chunk_index * DEFAULT_CHUNK_SIZE)
|
||||
|
||||
if len(chunk_data) != expected_size:
|
||||
# Rollback the recorded chunk
|
||||
meta_path = _get_upload_meta_path(upload_id)
|
||||
with open(meta_path, "r+", encoding="utf-8") as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
meta = json.load(f)
|
||||
if chunk_index in meta["uploaded_chunks"]:
|
||||
meta["uploaded_chunks"].remove(chunk_index)
|
||||
f.seek(0)
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||
f.truncate()
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Chunk size mismatch. Expected {expected_size}, got {len(chunk_data)}",
|
||||
)
|
||||
|
||||
# Save chunk
|
||||
chunk_path = _get_chunk_dir(upload_id) / f"chunk_{chunk_index:06d}"
|
||||
with open(chunk_path, "wb") as f:
|
||||
f.write(chunk_data)
|
||||
|
||||
# Reload metadata for response
|
||||
meta = _load_upload_meta(upload_id)
|
||||
|
||||
return {
|
||||
"message": "Chunk uploaded successfully",
|
||||
"chunk_index": chunk_index,
|
||||
"uploaded_chunks": len(meta["uploaded_chunks"]),
|
||||
"total_chunks": meta["total_chunks"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{upload_id}/status", response_model=ChunkedUploadStatusResponse)
|
||||
async def get_upload_status(
|
||||
upload_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ChunkedUploadStatusResponse:
|
||||
"""Get upload status (for resume)"""
|
||||
meta = _load_upload_meta(upload_id)
|
||||
|
||||
return ChunkedUploadStatusResponse(
|
||||
upload_id=upload_id,
|
||||
filename=meta["filename"],
|
||||
file_size=meta["file_size"],
|
||||
total_chunks=meta["total_chunks"],
|
||||
uploaded_chunks=sorted(meta["uploaded_chunks"]),
|
||||
status=meta["status"],
|
||||
created_at=datetime.fromisoformat(meta["created_at"]),
|
||||
expires_at=datetime.fromisoformat(meta["expires_at"]),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{upload_id}/complete", response_model=ChunkedUploadCompleteResponse)
|
||||
async def complete_chunked_upload(
|
||||
upload_id: str,
|
||||
request: ChunkedUploadCompleteRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> ChunkedUploadCompleteResponse:
|
||||
"""Complete chunked upload, merge chunks"""
|
||||
# Load metadata
|
||||
meta = _load_upload_meta(upload_id)
|
||||
|
||||
# Verify project ID and library ID
|
||||
if request.project_id != meta["project_id"] or request.library_id != meta["library_id"]:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Project or library ID mismatch")
|
||||
|
||||
# Verify all chunks are uploaded
|
||||
expected_chunks = set(range(meta["total_chunks"]))
|
||||
uploaded_chunks = set(meta["uploaded_chunks"])
|
||||
missing_chunks = expected_chunks - uploaded_chunks
|
||||
|
||||
if missing_chunks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Missing chunks: {sorted(missing_chunks)}. Please upload remaining chunks first.",
|
||||
)
|
||||
|
||||
# Validate file type
|
||||
chunk_dir = _get_chunk_dir(upload_id)
|
||||
sample_chunk_path = chunk_dir / "chunk_000000"
|
||||
if sample_chunk_path.exists():
|
||||
with open(sample_chunk_path, "rb") as f:
|
||||
sample_data = f.read(8192) # Read first 8KB for type detection
|
||||
detected_mime = _validate_file_type(sample_data, meta["filename"])
|
||||
if detected_mime not in ALLOWED_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported file type: {detected_mime}",
|
||||
)
|
||||
|
||||
# Merge chunks to temp file
|
||||
temp_file_path = CHUNK_STORAGE_ROOT / f"{upload_id}_complete.tmp"
|
||||
try:
|
||||
with open(temp_file_path, "wb") as out_file:
|
||||
for i in range(meta["total_chunks"]):
|
||||
chunk_path = chunk_dir / f"chunk_{i:06d}"
|
||||
with open(chunk_path, "rb") as in_file:
|
||||
shutil.copyfileobj(in_file, out_file)
|
||||
|
||||
# Verify file size
|
||||
actual_size = temp_file_path.stat().st_size
|
||||
if actual_size != meta["file_size"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"File size mismatch. Expected {meta['file_size']}, got {actual_size}",
|
||||
)
|
||||
|
||||
# Upload to OSS
|
||||
file_id = uuid4().hex[:8]
|
||||
safe_filename = meta["filename"]
|
||||
storage_key = f"uploads/{file_id}/{safe_filename}"
|
||||
|
||||
file_url = storage_service.upload_file(
|
||||
str(temp_file_path),
|
||||
storage_key,
|
||||
content_type=meta["content_type"],
|
||||
)
|
||||
|
||||
# Create ingest job
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
job = use_case.execute(
|
||||
SubmitIngestJobCommand(
|
||||
project_id=meta["project_id"],
|
||||
library_id=meta["library_id"],
|
||||
storage_key=storage_key,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
|
||||
# Update metadata status
|
||||
meta["status"] = "completed"
|
||||
_save_upload_meta(upload_id, meta)
|
||||
|
||||
return ChunkedUploadCompleteResponse(
|
||||
storage_key=storage_key,
|
||||
ingest_job_id=job.id,
|
||||
url=file_url,
|
||||
)
|
||||
|
||||
finally:
|
||||
# Cleanup temp file and chunks
|
||||
if temp_file_path.exists():
|
||||
temp_file_path.unlink()
|
||||
if chunk_dir.exists():
|
||||
shutil.rmtree(chunk_dir)
|
||||
# Delete metadata file
|
||||
meta_path = _get_upload_meta_path(upload_id)
|
||||
if meta_path.exists():
|
||||
meta_path.unlink()
|
||||
@@ -27,9 +27,10 @@ def get_classification_job(
|
||||
raise HTTPException(status_code=404, detail=f"ClassificationJob {job_id} not found")
|
||||
return ClassificationJobResponse(
|
||||
id=job.id,
|
||||
workspace_id=job.workspace_id,
|
||||
project_id=job.project_id,
|
||||
asset_id=job.asset_id,
|
||||
status=job.status,
|
||||
status=job.status.value,
|
||||
classification=job.classification,
|
||||
confidence=job.confidence,
|
||||
error_message=job.error_message,
|
||||
@@ -44,6 +45,7 @@ def submit_classification_job(
|
||||
use_case = SubmitClassificationJobUseCase(classification_job_repository)
|
||||
job = use_case.execute(
|
||||
SubmitClassificationJobCommand(
|
||||
workspace_id=request.workspace_id,
|
||||
project_id=request.project_id,
|
||||
asset_id=request.asset_id,
|
||||
)
|
||||
@@ -51,9 +53,10 @@ def submit_classification_job(
|
||||
celery_app.send_task("worker.classify_asset", args=[job.id])
|
||||
return ClassificationJobResponse(
|
||||
id=job.id,
|
||||
workspace_id=job.workspace_id,
|
||||
project_id=job.project_id,
|
||||
asset_id=job.asset_id,
|
||||
status=job.status,
|
||||
status=job.status.value,
|
||||
classification=job.classification,
|
||||
confidence=job.confidence,
|
||||
error_message=job.error_message,
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
"""查重 API 路由。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import get_duplication_repository
|
||||
from app.schemas.duplication import (
|
||||
DuplicationDetailResponse,
|
||||
DuplicationRecordResponse,
|
||||
DuplicationUploadResponse,
|
||||
DuplicateSegmentResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
|
||||
|
||||
from packages.application import (
|
||||
DeleteDuplicationRecordUseCase,
|
||||
GetDuplicationDetailUseCase,
|
||||
ListDuplicationRecordsUseCase,
|
||||
RetryDuplicationUseCase,
|
||||
UploadForDuplicationCommand,
|
||||
UploadForDuplicationUseCase,
|
||||
)
|
||||
from packages.domain.duplication import DuplicationRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 查重功能只接受视频文件
|
||||
ALLOWED_VIDEO_MIME_TYPES = frozenset({
|
||||
"video/mp4", "video/mpeg", "video/quicktime", "video/x-msvideo",
|
||||
"video/webm", "video/x-matroska", "video/3gpp",
|
||||
})
|
||||
|
||||
|
||||
def _validate_video_mime_type(content_type: str | None) -> str:
|
||||
"""验证视频文件的 MIME 类型,如果无效则抛出异常。"""
|
||||
if not content_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Content-Type header is required",
|
||||
)
|
||||
|
||||
# 处理带参数的类型,如 "video/mp4; charset=utf-8"
|
||||
base_type = content_type.split(";")[0].strip().lower()
|
||||
|
||||
if base_type not in ALLOWED_VIDEO_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail=f"只支持视频文件。支持的类型: mp4, mpeg, mov, avi, webm, mkv, 3gp",
|
||||
)
|
||||
|
||||
return base_type
|
||||
|
||||
|
||||
def _to_record_response(record: DuplicationRecord) -> DuplicationRecordResponse:
|
||||
return DuplicationRecordResponse(
|
||||
id=record.id,
|
||||
filename=record.filename,
|
||||
file_size=record.file_size,
|
||||
duration_seconds=record.duration_seconds,
|
||||
status=record.status,
|
||||
duplicate_rate=record.duplicate_rate,
|
||||
duplicate_count=record.duplicate_count,
|
||||
created_at=record.created_at.isoformat(),
|
||||
updated_at=record.updated_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _to_detail_response(record: DuplicationRecord) -> DuplicationDetailResponse:
|
||||
return DuplicationDetailResponse(
|
||||
id=record.id,
|
||||
filename=record.filename,
|
||||
file_size=record.file_size,
|
||||
duration_seconds=record.duration_seconds,
|
||||
status=record.status,
|
||||
duplicate_rate=record.duplicate_rate,
|
||||
duplicate_count=record.duplicate_count,
|
||||
created_at=record.created_at.isoformat(),
|
||||
updated_at=record.updated_at.isoformat(),
|
||||
segments=[
|
||||
DuplicateSegmentResponse(
|
||||
id=seg.id,
|
||||
source_start=seg.source_start,
|
||||
source_end=seg.source_end,
|
||||
matched_video_id=seg.matched_video_id,
|
||||
matched_video_name=seg.matched_video_name,
|
||||
matched_start=seg.matched_start,
|
||||
matched_end=seg.matched_end,
|
||||
similarity=seg.similarity,
|
||||
)
|
||||
for seg in record.segments
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_model=DuplicationUploadResponse)
|
||||
async def upload_for_duplication(
|
||||
file: UploadFile = File(..., description="要查重的视频文件"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> DuplicationUploadResponse:
|
||||
"""上传视频进行查重。"""
|
||||
if file.filename is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文件名不能为空",
|
||||
)
|
||||
|
||||
# P0-1: 验证 MIME 类型(只接受视频文件)
|
||||
validated_content_type = _validate_video_mime_type(file.content_type)
|
||||
|
||||
# P0-2: 验证文件大小(参考 OSS_DIRECT_UPLOAD_MAX_MB)
|
||||
from app.config import get_settings
|
||||
settings = get_settings()
|
||||
max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024
|
||||
|
||||
# 先检查 Content-Length header(如果可用)
|
||||
if file.size is not None and file.size > max_size_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"文件超过上传限制 ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
|
||||
)
|
||||
|
||||
# 读取文件内容并上传到 OSS
|
||||
file_id = uuid4().hex[:8]
|
||||
safe_filename = file.filename.replace("/", "_").replace("\\", "_")
|
||||
storage_key = f"duplication/{file_id}/{safe_filename}"
|
||||
|
||||
try:
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
# 再次检查实际文件大小
|
||||
if file_size > max_size_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"文件超过上传限制 ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("读取查重文件失败: %s", exc, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文件读取失败,请稍后重试",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
storage_service.upload_file(
|
||||
content,
|
||||
storage_key,
|
||||
content_type=validated_content_type,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("查重文件上传 OSS 失败: %s", exc, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="文件上传失败,请稍后重试",
|
||||
) from exc
|
||||
|
||||
use_case = UploadForDuplicationUseCase(duplication_repository)
|
||||
record = use_case.execute(
|
||||
UploadForDuplicationCommand(
|
||||
user_id=authenticated_user.user.id,
|
||||
filename=file.filename,
|
||||
file_size=file_size,
|
||||
storage_key=storage_key,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Duplication upload: record=%s file=%s user=%s",
|
||||
record.id,
|
||||
file.filename,
|
||||
authenticated_user.user.id,
|
||||
)
|
||||
|
||||
return DuplicationUploadResponse(
|
||||
id=record.id,
|
||||
status=record.status,
|
||||
message=f'文件 "{file.filename}" 已上传,正在查重中...',
|
||||
)
|
||||
|
||||
|
||||
@router.get("/records", response_model=list[DuplicationRecordResponse])
|
||||
def list_duplication_records(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
) -> list[DuplicationRecordResponse]:
|
||||
"""获取当前用户的查重记录列表。"""
|
||||
use_case = ListDuplicationRecordsUseCase(duplication_repository)
|
||||
records = use_case.execute(authenticated_user.user.id)
|
||||
return [_to_record_response(r) for r in records]
|
||||
|
||||
|
||||
@router.get("/records/{record_id}", response_model=DuplicationDetailResponse)
|
||||
def get_duplication_detail(
|
||||
record_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
) -> DuplicationDetailResponse:
|
||||
"""获取查重记录详情(含重复片段)。"""
|
||||
use_case = GetDuplicationDetailUseCase(duplication_repository)
|
||||
record = use_case.execute(record_id)
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
if record.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
return _to_detail_response(record)
|
||||
|
||||
|
||||
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||
def delete_duplication_record(
|
||||
record_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
) -> Response:
|
||||
"""删除查重记录。"""
|
||||
# 检查记录是否存在且属于当前用户
|
||||
detail_uc = GetDuplicationDetailUseCase(duplication_repository)
|
||||
record = detail_uc.execute(record_id)
|
||||
if record is None or record.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
|
||||
use_case = DeleteDuplicationRecordUseCase(duplication_repository)
|
||||
use_case.execute(record_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/records/{record_id}/retry", response_model=DuplicationUploadResponse)
|
||||
def retry_duplication(
|
||||
record_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
) -> DuplicationUploadResponse:
|
||||
"""重新提交查重。"""
|
||||
# 检查记录存在且属于当前用户
|
||||
detail_uc = GetDuplicationDetailUseCase(duplication_repository)
|
||||
record = detail_uc.execute(record_id)
|
||||
if record is None or record.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
|
||||
use_case = RetryDuplicationUseCase(duplication_repository)
|
||||
updated = use_case.execute(record_id)
|
||||
if updated is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
|
||||
return DuplicationUploadResponse(
|
||||
id=updated.id,
|
||||
status=updated.status,
|
||||
message="已重新提交查重",
|
||||
)
|
||||
@@ -1,28 +1,30 @@
|
||||
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
|
||||
from app.dependencies import get_generated_video_repository, get_project_repository, get_workspace_member_repository
|
||||
from app.schemas.generated_video import (
|
||||
GeneratedVideoDownloadUrlResponse,
|
||||
GeneratedVideoResponse,
|
||||
ListGeneratedVideosResponse,
|
||||
UpdateGeneratedVideoReviewRequest,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
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, download_url: str | None = None) -> GeneratedVideoResponse:
|
||||
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,
|
||||
@@ -33,44 +35,24 @@ def _to_generated_video_response(item, download_url: str | None = None) -> Gener
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
fps=item.fps,
|
||||
status=item.status,
|
||||
review_status=item.review_status,
|
||||
generation_params=item.generation_params,
|
||||
download_url=download_url,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ListGeneratedVideosResponse)
|
||||
def list_generated_videos(
|
||||
project_id: str | None = Query(None),
|
||||
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),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ListGeneratedVideosResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
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)
|
||||
|
||||
if project_id:
|
||||
# If project_id provided, check access and filter by project
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
items = use_case.execute(project_id)
|
||||
else:
|
||||
# If no project_id, list all videos from accessible projects
|
||||
accessible_projects = project_repository.find_accessible_projects(user_id)
|
||||
all_items = []
|
||||
for proj in accessible_projects:
|
||||
all_items.extend(use_case.execute(proj.id))
|
||||
items = all_items
|
||||
|
||||
# Generate download URLs for each video
|
||||
responses = []
|
||||
for item in items:
|
||||
download_url = storage_service.get_download_url(item.file_url)
|
||||
responses.append(_to_generated_video_response(item, download_url=download_url))
|
||||
return ListGeneratedVideosResponse(items=responses)
|
||||
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)
|
||||
@@ -78,31 +60,14 @@ def get_generated_video(
|
||||
video_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
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")
|
||||
download_url = storage_service.get_download_url(item.file_url)
|
||||
return _to_generated_video_response(item, download_url=download_url)
|
||||
|
||||
|
||||
@router.patch("/{video_id}/review", response_model=GeneratedVideoResponse)
|
||||
def update_generated_video_review_status(
|
||||
video_id: str,
|
||||
request: UpdateGeneratedVideoReviewRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> GeneratedVideoResponse:
|
||||
video = generated_video_repository.get(video_id)
|
||||
if video is None:
|
||||
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
||||
video.review_status = request.review_status
|
||||
updated = generated_video_repository.update(video)
|
||||
download_url = storage_service.get_download_url(updated.file_url)
|
||||
return _to_generated_video_response(updated, download_url=download_url)
|
||||
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)
|
||||
@@ -110,11 +75,13 @@ 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:
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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_asset_repository,
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
get_project_repository,
|
||||
get_workspace_member_repository,
|
||||
)
|
||||
from app.schemas.generated_video import (
|
||||
GeneratedVideoResponse,
|
||||
@@ -25,27 +26,20 @@ from packages.application import (
|
||||
GetGenerationTaskUseCase,
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
||||
"""检查用户是否有项目访问权限"""
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
if not project.can_access(user_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
|
||||
def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
return GenerationTaskResponse(
|
||||
id=task.id,
|
||||
workspace_id=task.workspace_id,
|
||||
project_id=task.project_id,
|
||||
asset_library_id=task.asset_library_id,
|
||||
strategy_id=task.strategy_id,
|
||||
voice_library_id=task.voice_library_id,
|
||||
status=task.status,
|
||||
status=task.status.value,
|
||||
progress=task.progress,
|
||||
result_count=task.result_count,
|
||||
error_message=task.error_message,
|
||||
@@ -55,6 +49,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
def _to_generated_video_response(item) -> GeneratedVideoResponse:
|
||||
return GeneratedVideoResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
generation_task_id=item.generation_task_id,
|
||||
name=item.name,
|
||||
@@ -68,17 +63,6 @@ def _to_generated_video_response(item) -> GeneratedVideoResponse:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_library_has_ready_video_assets(assets) -> None:
|
||||
ready_video_assets = [
|
||||
asset for asset in assets if asset.status.value == "ready" and asset.mime_type.startswith("video")
|
||||
]
|
||||
if not ready_video_assets:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="当前素材库没有 ready 状态的视频素材,请先上传并等待导入完成后再生成。",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=GenerationTaskResponse)
|
||||
def create_generation_task(
|
||||
request: CreateGenerationTaskRequest,
|
||||
@@ -86,24 +70,20 @@ def create_generation_task(
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
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:
|
||||
if project is None or project.workspace_id != request.workspace_id:
|
||||
raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found")
|
||||
if not project.can_access(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
library = asset_library_repository.get(request.asset_library_id)
|
||||
if library is None or library.project_id != request.project_id:
|
||||
if library is None or library.workspace_id != request.workspace_id or library.project_id != request.project_id:
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.asset_library_id} not found")
|
||||
|
||||
assets = asset_repository.list_by_library(request.asset_library_id)
|
||||
_ensure_library_has_ready_video_assets(assets)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
workspace_id=request.workspace_id,
|
||||
project_id=request.project_id,
|
||||
asset_library_id=request.asset_library_id,
|
||||
strategy_id=request.strategy_id,
|
||||
@@ -120,13 +100,13 @@ def get_generation_task(
|
||||
task_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
project_repository: Any = Depends(get_project_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")
|
||||
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
||||
require_workspace_member(task.workspace_id, authenticated_user, workspace_member_repository)
|
||||
return _to_generation_task_response(task)
|
||||
|
||||
|
||||
@@ -136,12 +116,12 @@ def list_generation_results(
|
||||
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),
|
||||
project_repository: Any = Depends(get_project_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")
|
||||
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
||||
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])
|
||||
|
||||
@@ -20,6 +20,7 @@ def get_ingest_job(
|
||||
raise ValueError(f"IngestJob {job_id} not found")
|
||||
return IngestJobResponse(
|
||||
id=job.id,
|
||||
workspace_id=job.workspace_id,
|
||||
project_id=job.project_id,
|
||||
library_id=job.library_id,
|
||||
storage_key=job.storage_key,
|
||||
@@ -37,6 +38,7 @@ def submit_ingest_job(
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
job = use_case.execute(
|
||||
SubmitIngestJobCommand(
|
||||
workspace_id=request.workspace_id,
|
||||
project_id=request.project_id,
|
||||
library_id=request.library_id,
|
||||
storage_key=request.storage_key,
|
||||
@@ -47,6 +49,7 @@ def submit_ingest_job(
|
||||
|
||||
return IngestJobResponse(
|
||||
id=job.id,
|
||||
workspace_id=job.workspace_id,
|
||||
project_id=job.project_id,
|
||||
library_id=job.library_id,
|
||||
storage_key=job.storage_key,
|
||||
|
||||
@@ -1,2 +1,16 @@
|
||||
# Compatibility module - workspace concept has been removed.
|
||||
# All permission checks are handled at the project level (see packages.domain.permissions).
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
"""项目管理 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,
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_project_repository
|
||||
from app.dependencies import get_project_repository, get_workspace_member_repository
|
||||
from app.schemas.project import (
|
||||
CreateProjectRequest,
|
||||
ListProjectsResponse,
|
||||
@@ -15,13 +15,25 @@ from packages.application import (
|
||||
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,
|
||||
)
|
||||
@@ -32,21 +44,26 @@ 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(authenticated_user.user.id)
|
||||
projects = use_case.execute(workspace_id)
|
||||
return ListProjectsResponse(items=[_to_project_response(item) for item in projects])
|
||||
|
||||
|
||||
@@ -55,13 +72,15 @@ 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,
|
||||
),
|
||||
owner_user_id=authenticated_user.user.id,
|
||||
)
|
||||
)
|
||||
return _to_project_response(project)
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
"""Subscription management API routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_user_repository
|
||||
from app.schemas.subscription import (
|
||||
BillingRecord,
|
||||
ChangePlanRequest,
|
||||
ChangePlanResponse,
|
||||
SimpleResponse,
|
||||
SubscriptionInfo,
|
||||
ToggleAutoRenewRequest,
|
||||
)
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============ 配额定义(硬编码,后续可迁移到配置中心) ============
|
||||
|
||||
PLAN_QUOTAS = {
|
||||
"free": {"max_projects": 3, "max_storage_gb": 10},
|
||||
"standard": {"max_projects": 10, "max_storage_gb": 50},
|
||||
"pro": {"max_projects": -1, "max_storage_gb": 100},
|
||||
"enterprise": {"max_projects": -1, "max_storage_gb": 1000},
|
||||
}
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
def _get_plan_name(plan_id: str) -> str:
|
||||
"""获取套餐显示名称"""
|
||||
plan_names = {
|
||||
"free": "体验版",
|
||||
"standard": "标准版",
|
||||
"pro": "专业版",
|
||||
"enterprise": "企业版",
|
||||
}
|
||||
return plan_names.get(plan_id, "未知套餐")
|
||||
|
||||
|
||||
def _get_plan_price(plan_id: str, billing_cycle: str) -> float:
|
||||
"""获取套餐价格"""
|
||||
prices = {
|
||||
("free", "monthly"): 0,
|
||||
("free", "yearly"): 0,
|
||||
("standard", "monthly"): 99,
|
||||
("standard", "yearly"): 999,
|
||||
("pro", "monthly"): 299,
|
||||
("pro", "yearly"): 2999,
|
||||
("enterprise", "monthly"): 999,
|
||||
("enterprise", "yearly"): 9999,
|
||||
}
|
||||
return prices.get((plan_id, billing_cycle), 0)
|
||||
|
||||
|
||||
def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo:
|
||||
"""构建订阅信息响应"""
|
||||
now = datetime.now(timezone.utc)
|
||||
if user.user.subscription_expires_at:
|
||||
period_end = user.user.subscription_expires_at.isoformat()
|
||||
period_start = now.isoformat()
|
||||
else:
|
||||
period_start = now.isoformat()
|
||||
period_end = now.isoformat()
|
||||
|
||||
return SubscriptionInfo(
|
||||
id=f"sub-{user.user.id[:8]}",
|
||||
plan_id=user.user.subscription_plan or "free",
|
||||
plan_name=_get_plan_name(user.user.subscription_plan or "free"),
|
||||
status=user.user.subscription_status or "active",
|
||||
billing_cycle="monthly",
|
||||
current_period_start=period_start,
|
||||
current_period_end=period_end,
|
||||
amount=_get_plan_price(user.user.subscription_plan or "free", "monthly"),
|
||||
auto_renew=True,
|
||||
created_at=user.user.created_at.isoformat() if user.user.created_at else now.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
# ============ API Endpoints ============
|
||||
|
||||
@router.get("/current", response_model=SubscriptionInfo)
|
||||
async def get_current_subscription(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""获取当前订阅信息"""
|
||||
return _build_subscription_info(current_user)
|
||||
|
||||
|
||||
@router.get("/billing-records", response_model=List[BillingRecord])
|
||||
async def get_billing_records(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""获取账单记录列表"""
|
||||
# TODO: 从数据库查询账单记录
|
||||
return []
|
||||
|
||||
|
||||
@router.post("/change-plan", response_model=ChangePlanResponse)
|
||||
async def change_plan(
|
||||
request: ChangePlanRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
):
|
||||
"""变更订阅套餐(升级/降级)"""
|
||||
# TODO: 接入支付验证(支付宝/微信支付)
|
||||
valid_plans = {"free", "standard", "pro", "enterprise"}
|
||||
if request.target_plan_id not in valid_plans:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无效的套餐ID。支持的套餐: {', '.join(valid_plans)}",
|
||||
)
|
||||
|
||||
valid_cycles = {"monthly", "yearly"}
|
||||
if request.billing_cycle not in valid_cycles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的计费周期。支持: monthly, yearly",
|
||||
)
|
||||
|
||||
user = current_user.user
|
||||
current_plan = user.subscription_plan or "free"
|
||||
target_plan = request.target_plan_id
|
||||
|
||||
if current_plan == target_plan:
|
||||
return ChangePlanResponse(
|
||||
success=False,
|
||||
message=f"您已经是 {_get_plan_name(target_plan)}",
|
||||
)
|
||||
|
||||
# 通过 dataclasses.replace 创建新实例(不直接修改 dataclass)
|
||||
quotas = PLAN_QUOTAS.get(target_plan, PLAN_QUOTAS["free"])
|
||||
updated_user = replace(
|
||||
user,
|
||||
subscription_plan=target_plan,
|
||||
subscription_status="active",
|
||||
max_projects=quotas["max_projects"],
|
||||
max_storage_gb=quotas["max_storage_gb"],
|
||||
)
|
||||
user_repository.save(updated_user)
|
||||
|
||||
# 用更新后的用户构造响应
|
||||
refreshed_auth_user = AuthenticatedUser(user=updated_user)
|
||||
|
||||
return ChangePlanResponse(
|
||||
success=True,
|
||||
message=f"套餐已成功变更为 {_get_plan_name(target_plan)}",
|
||||
new_subscription=_build_subscription_info(refreshed_auth_user),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cancel", response_model=SimpleResponse)
|
||||
async def cancel_subscription(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
):
|
||||
"""取消订阅"""
|
||||
user = current_user.user
|
||||
if user.subscription_plan == "free":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="体验版无需取消",
|
||||
)
|
||||
|
||||
updated_user = replace(user, subscription_status="cancelled")
|
||||
user_repository.save(updated_user)
|
||||
|
||||
return SimpleResponse(
|
||||
success=True,
|
||||
message="订阅已取消,当前周期结束后停止服务",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/toggle-auto-renew", response_model=SimpleResponse)
|
||||
async def toggle_auto_renew(
|
||||
request: ToggleAutoRenewRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""切换自动续费"""
|
||||
# TODO: 实际需要在数据库中存储 auto_renew 字段
|
||||
status_text = "已开启自动续费" if request.enabled else "已关闭自动续费"
|
||||
|
||||
return SimpleResponse(
|
||||
success=True,
|
||||
message=status_text,
|
||||
)
|
||||
@@ -1,175 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import (
|
||||
get_generation_task_repository,
|
||||
get_ingest_job_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
from app.schemas.task_center import ListProjectTasksResponse, ProjectTaskResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
SubmitIngestJobCommand,
|
||||
SubmitIngestJobUseCase,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _humanize_task_error(error_message: str) -> str:
|
||||
raw = (error_message or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
lower = raw.lower()
|
||||
if "ffmpeg" in lower or "ffprobe" in lower or "invalid data" in lower or "moov atom" in lower:
|
||||
return "视频素材格式无法识别,请重新导出为常见 MP4/H.264 后再试。"
|
||||
if "oss" in lower or "bucket" in lower or "storage" in lower:
|
||||
return "素材存储服务读取或写入失败,请稍后重试或联系小虾检查 OSS。"
|
||||
if "not found" in lower or "no such file" in lower:
|
||||
return "任务依赖的素材或文件不存在,请确认素材仍在项目中。"
|
||||
return f"任务失败:{raw}"
|
||||
|
||||
|
||||
def _generation_step(task) -> str:
|
||||
if task.status.value == "pending":
|
||||
return "等待 Worker 执行"
|
||||
if task.status.value == "running":
|
||||
return "正在生成成片"
|
||||
if task.status.value == "completed":
|
||||
return "生成完成"
|
||||
if task.status.value == "failed":
|
||||
return "生成失败"
|
||||
return task.status.value
|
||||
|
||||
|
||||
def _ingest_step(job) -> str:
|
||||
if job.status.value == "pending":
|
||||
return "等待导入"
|
||||
if job.status.value == "processing":
|
||||
return "正在分析素材"
|
||||
if job.status.value == "completed":
|
||||
return "导入完成"
|
||||
if job.status.value == "failed":
|
||||
return "导入失败"
|
||||
return job.status.value
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse)
|
||||
def list_project_tasks(
|
||||
project_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
) -> ListProjectTasksResponse:
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
items: list[ProjectTaskResponse] = []
|
||||
for job in ingest_job_repository.list_by_project(project_id):
|
||||
items.append(
|
||||
ProjectTaskResponse(
|
||||
id=f"ingest:{job.id}",
|
||||
task_type="ingest",
|
||||
project_id=job.project_id,
|
||||
status=job.status.value,
|
||||
progress=100.0 if job.status.value == "completed" else 0.0,
|
||||
current_step=_ingest_step(job),
|
||||
error_message=job.error_message,
|
||||
user_message=_humanize_task_error(job.error_message),
|
||||
retryable=job.status.value == "failed",
|
||||
source_id=job.id,
|
||||
created_at=job.created_at,
|
||||
updated_at=job.updated_at,
|
||||
)
|
||||
)
|
||||
for task in generation_task_repository.list_by_project(project_id):
|
||||
items.append(
|
||||
ProjectTaskResponse(
|
||||
id=f"generation:{task.id}",
|
||||
task_type="generation",
|
||||
project_id=task.project_id,
|
||||
status=task.status.value,
|
||||
progress=task.progress,
|
||||
current_step=_generation_step(task),
|
||||
error_message=task.error_message,
|
||||
user_message=_humanize_task_error(task.error_message),
|
||||
retryable=task.status.value == "failed",
|
||||
source_id=task.id,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.completed_at or task.started_at or task.created_at,
|
||||
)
|
||||
)
|
||||
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
|
||||
return ListProjectTasksResponse(items=items)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_type}/{source_id}/retry", response_model=ProjectTaskResponse)
|
||||
def retry_project_task(
|
||||
task_type: str,
|
||||
source_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
) -> ProjectTaskResponse:
|
||||
if task_type == "generation":
|
||||
task = generation_task_repository.get(source_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Generation task not found")
|
||||
if task.status.value != "failed":
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
retried = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=task.project_id,
|
||||
asset_library_id=task.asset_library_id,
|
||||
strategy_id=task.strategy_id,
|
||||
voice_library_id=task.voice_library_id,
|
||||
edit_plan_id=task.edit_plan_id,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[retried.id])
|
||||
return ProjectTaskResponse(
|
||||
id=f"generation:{retried.id}",
|
||||
task_type="generation",
|
||||
project_id=retried.project_id,
|
||||
status=retried.status.value,
|
||||
progress=retried.progress,
|
||||
current_step=_generation_step(retried),
|
||||
source_id=retried.id,
|
||||
created_at=retried.created_at,
|
||||
updated_at=retried.created_at,
|
||||
)
|
||||
if task_type == "ingest":
|
||||
job = ingest_job_repository.get(source_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Ingest job not found")
|
||||
if job.status.value != "failed":
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
retried = use_case.execute(
|
||||
SubmitIngestJobCommand(
|
||||
project_id=job.project_id,
|
||||
library_id=job.library_id,
|
||||
storage_key=job.storage_key,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.ingest_asset", args=[retried.id])
|
||||
return ProjectTaskResponse(
|
||||
id=f"ingest:{retried.id}",
|
||||
task_type="ingest",
|
||||
project_id=retried.project_id,
|
||||
status=retried.status.value,
|
||||
progress=0,
|
||||
current_step=_ingest_step(retried),
|
||||
source_id=retried.id,
|
||||
created_at=retried.created_at,
|
||||
updated_at=retried.updated_at,
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Unsupported task type")
|
||||
@@ -1,156 +0,0 @@
|
||||
"""Title library CRUD routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_user_repository
|
||||
from app.schemas.title_library import (
|
||||
CreateTitleLibraryRequest,
|
||||
ListTitleLibraryResponse,
|
||||
TitleLibraryItemResponse,
|
||||
UpdateTitleLibraryRequest,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.title_library_repository import SQLAlchemyTitleLibraryRepository
|
||||
from packages.application.title_library.commands import CreateTitleLibraryCommand, UpdateTitleLibraryCommand
|
||||
from packages.application.title_library.use_cases import (
|
||||
CreateTitleLibraryUseCase,
|
||||
DeleteTitleLibraryUseCase,
|
||||
GetTitleLibraryUseCase,
|
||||
ListTitleLibraryUseCase,
|
||||
UpdateTitleLibraryUseCase,
|
||||
NotFoundError,
|
||||
QuotaExceededError,
|
||||
)
|
||||
from packages.ports.user_repository import UserRepository
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_title_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTitleLibraryRepository:
|
||||
return SQLAlchemyTitleLibraryRepository(session)
|
||||
|
||||
|
||||
def _to_response(item) -> TitleLibraryItemResponse:
|
||||
return TitleLibraryItemResponse(
|
||||
id=item.id,
|
||||
user_id=item.user_id,
|
||||
name=item.name,
|
||||
text=item.text,
|
||||
category=item.category,
|
||||
description=item.description,
|
||||
tags=item.tags,
|
||||
usage_count=item.usage_count,
|
||||
is_active=item.is_active,
|
||||
created_at=item.created_at,
|
||||
updated_at=item.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
|
||||
user = user_repository.find_by_id(user_id)
|
||||
if user is None:
|
||||
return "free"
|
||||
return getattr(user, "subscription_plan", "free") or "free"
|
||||
|
||||
|
||||
@router.get("", response_model=ListTitleLibraryResponse)
|
||||
def list_titles(
|
||||
category: Optional[str] = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
|
||||
) -> ListTitleLibraryResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = ListTitleLibraryUseCase(title_repository)
|
||||
items = use_case.execute(user_id, category=category, skip=skip, limit=limit)
|
||||
total = title_repository.count_by_user(user_id)
|
||||
return ListTitleLibraryResponse(
|
||||
items=[_to_response(i) for i in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{title_id}", response_model=TitleLibraryItemResponse)
|
||||
def get_title(
|
||||
title_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
|
||||
) -> TitleLibraryItemResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = GetTitleLibraryUseCase(title_repository)
|
||||
item = use_case.execute(title_id, user_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@router.post("", response_model=TitleLibraryItemResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_title(
|
||||
request: CreateTitleLibraryRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> TitleLibraryItemResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
plan_name = _get_user_plan(user_id, user_repository)
|
||||
command = CreateTitleLibraryCommand(
|
||||
user_id=user_id,
|
||||
name=request.name,
|
||||
text=request.text,
|
||||
category=request.category,
|
||||
description=request.description,
|
||||
tags=request.tags,
|
||||
)
|
||||
use_case = CreateTitleLibraryUseCase(title_repository)
|
||||
try:
|
||||
item = use_case.execute(command, plan_name=plan_name)
|
||||
except QuotaExceededError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||
)
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@router.put("/{title_id}", response_model=TitleLibraryItemResponse)
|
||||
def update_title(
|
||||
title_id: str,
|
||||
request: UpdateTitleLibraryRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
|
||||
) -> TitleLibraryItemResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
command = UpdateTitleLibraryCommand(
|
||||
title_id=title_id,
|
||||
user_id=user_id,
|
||||
name=request.name,
|
||||
text=request.text,
|
||||
category=request.category,
|
||||
description=request.description,
|
||||
tags=request.tags,
|
||||
)
|
||||
use_case = UpdateTitleLibraryUseCase(title_repository)
|
||||
try:
|
||||
item = use_case.execute(command)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||
def delete_title(
|
||||
title_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
|
||||
) -> Response:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = DeleteTitleLibraryUseCase(title_repository)
|
||||
deleted = use_case.execute(title_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
|
||||
return Response(status_code=204)
|
||||
@@ -2,80 +2,58 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import get_settings
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import 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,
|
||||
UploadAssetRequest,
|
||||
UploadAssetResponse,
|
||||
)
|
||||
from typing import Annotated
|
||||
|
||||
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()
|
||||
|
||||
# 允许上传的文件 MIME 类型
|
||||
ALLOWED_MIME_TYPES = frozenset({
|
||||
# 视频
|
||||
"video/mp4", "video/mpeg", "video/quicktime", "video/x-msvideo",
|
||||
"video/webm", "video/x-matroska", "video/3gpp",
|
||||
# 音频
|
||||
"audio/mpeg", "audio/wav", "audio/ogg", "audio/flac", "audio/aac",
|
||||
"audio/mp3", "audio/x-m4a", "audio/webm",
|
||||
# 图片
|
||||
"image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp",
|
||||
"image/svg+xml", "image/tiff",
|
||||
})
|
||||
|
||||
|
||||
def _validate_mime_type(content_type: str | None) -> str:
|
||||
"""验证并返回标准化的 MIME 类型,如果无效则抛出异常。"""
|
||||
if not content_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Content-Type header is required",
|
||||
)
|
||||
|
||||
# 处理带参数的类型,如 "video/mp4; charset=utf-8"
|
||||
base_type = content_type.split(";")[0].strip().lower()
|
||||
|
||||
if base_type not in ALLOWED_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail=f"File type '{base_type}' is not supported. Allowed types: video, audio, and image files.",
|
||||
)
|
||||
|
||||
return base_type
|
||||
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:
|
||||
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 for item in libraries):
|
||||
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,
|
||||
@@ -84,6 +62,7 @@ def _submit_ingest_job(
|
||||
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,
|
||||
@@ -99,6 +78,7 @@ async def prepare_direct_upload(
|
||||
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 的短期表单签名。"""
|
||||
@@ -110,10 +90,9 @@ async def prepare_direct_upload(
|
||||
detail=f"File exceeds upload limit ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
|
||||
)
|
||||
|
||||
# P2-5: 服务端验证 MIME 类型
|
||||
validated_content_type = _validate_mime_type(request.content_type)
|
||||
|
||||
_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,
|
||||
@@ -126,7 +105,7 @@ async def prepare_direct_upload(
|
||||
try:
|
||||
payload = storage_service.create_direct_upload_post(
|
||||
storage_key=storage_key,
|
||||
content_type=validated_content_type,
|
||||
content_type=request.content_type or "application/octet-stream",
|
||||
max_size_bytes=max_size_bytes,
|
||||
expires_seconds=settings.OSS_DIRECT_UPLOAD_EXPIRE_SECONDS,
|
||||
)
|
||||
@@ -150,10 +129,13 @@ async def complete_direct_upload(
|
||||
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,
|
||||
@@ -166,6 +148,7 @@ async def complete_direct_upload(
|
||||
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,
|
||||
@@ -174,40 +157,34 @@ async def complete_direct_upload(
|
||||
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=UploadAssetResponse,
|
||||
summary="Upload Asset",
|
||||
description="上传素材文件(multipart/form-data),支持视频、音频、图片。触发导入流水线自动处理。",
|
||||
)
|
||||
@router.post("", response_model=UploadAssetResponse)
|
||||
async def upload_asset(
|
||||
form_data: Annotated[UploadAssetRequest, Form()],
|
||||
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:
|
||||
"""上传素材文件并触发导入流水线。"""
|
||||
project_id = form_data.project_id
|
||||
library_id = form_data.library_id
|
||||
_require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
|
||||
|
||||
# P2-5: 服务端验证 MIME 类型
|
||||
validated_content_type = _validate_mime_type(file.content_type)
|
||||
_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]
|
||||
safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else "unknown"
|
||||
storage_key = f"uploads/{file_id}/{safe_filename}"
|
||||
storage_key = f"uploads/{file_id}/{file.filename}"
|
||||
|
||||
file_url = storage_service.upload_file(
|
||||
file.file,
|
||||
storage_key,
|
||||
content_type=validated_content_type,
|
||||
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,
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
"""Voice library CRUD routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_user_repository
|
||||
from app.schemas.voice_library import (
|
||||
CreateVoiceLibraryRequest,
|
||||
ListVoiceLibraryResponse,
|
||||
VoiceLibraryItemResponse,
|
||||
UpdateVoiceLibraryRequest,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
|
||||
from packages.application.voice_library.commands import CreateVoiceLibraryCommand, UpdateVoiceLibraryCommand
|
||||
from packages.application.voice_library.use_cases import (
|
||||
CreateVoiceLibraryUseCase,
|
||||
DeleteVoiceLibraryUseCase,
|
||||
GetVoiceLibraryUseCase,
|
||||
ListVoiceLibraryUseCase,
|
||||
UpdateVoiceLibraryUseCase,
|
||||
NotFoundError,
|
||||
QuotaExceededError,
|
||||
)
|
||||
from packages.ports.user_repository import UserRepository
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository:
|
||||
return SQLAlchemyVoiceLibraryRepository(session)
|
||||
|
||||
|
||||
def _to_response(item) -> VoiceLibraryItemResponse:
|
||||
return VoiceLibraryItemResponse(
|
||||
id=item.id,
|
||||
user_id=item.user_id,
|
||||
name=item.name,
|
||||
text=item.text,
|
||||
voice_provider=item.voice_provider,
|
||||
voice_id=item.voice_id,
|
||||
voice_name=item.voice_name,
|
||||
audio_url=item.audio_url,
|
||||
duration=item.duration,
|
||||
file_size=item.file_size,
|
||||
status=item.status,
|
||||
project_id=item.project_id,
|
||||
tags=item.tags,
|
||||
created_at=item.created_at,
|
||||
updated_at=item.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
|
||||
user = user_repository.find_by_id(user_id)
|
||||
if user is None:
|
||||
return "free"
|
||||
return getattr(user, "subscription_plan", "free") or "free"
|
||||
|
||||
|
||||
@router.get("", response_model=ListVoiceLibraryResponse)
|
||||
def list_voices(
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
) -> ListVoiceLibraryResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = ListVoiceLibraryUseCase(voice_repository)
|
||||
items = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
|
||||
total = voice_repository.count_by_user(user_id)
|
||||
return ListVoiceLibraryResponse(
|
||||
items=[_to_response(i) for i in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{voice_id}", response_model=VoiceLibraryItemResponse)
|
||||
def get_voice(
|
||||
voice_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
) -> VoiceLibraryItemResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = GetVoiceLibraryUseCase(voice_repository)
|
||||
item = use_case.execute(voice_id, user_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@router.post("", response_model=VoiceLibraryItemResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_voice(
|
||||
request: CreateVoiceLibraryRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> VoiceLibraryItemResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
plan_name = _get_user_plan(user_id, user_repository)
|
||||
command = CreateVoiceLibraryCommand(
|
||||
user_id=user_id,
|
||||
name=request.name,
|
||||
text=request.text,
|
||||
voice_provider=request.voice_provider,
|
||||
voice_id=request.voice_id,
|
||||
voice_name=request.voice_name,
|
||||
audio_url=request.audio_url,
|
||||
duration=request.duration,
|
||||
file_size=request.file_size,
|
||||
status=request.status,
|
||||
project_id=request.project_id,
|
||||
tags=request.tags,
|
||||
)
|
||||
use_case = CreateVoiceLibraryUseCase(voice_repository)
|
||||
try:
|
||||
item = use_case.execute(command, plan_name=plan_name)
|
||||
except QuotaExceededError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||
)
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@router.put("/{voice_id}", response_model=VoiceLibraryItemResponse)
|
||||
def update_voice(
|
||||
voice_id: str,
|
||||
request: UpdateVoiceLibraryRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
) -> VoiceLibraryItemResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
command = UpdateVoiceLibraryCommand(
|
||||
id=voice_id,
|
||||
user_id=user_id,
|
||||
name=request.name,
|
||||
text=request.text,
|
||||
voice_provider=request.voice_provider,
|
||||
voice_id=request.voice_id,
|
||||
voice_name=request.voice_name,
|
||||
audio_url=request.audio_url,
|
||||
duration=request.duration,
|
||||
file_size=request.file_size,
|
||||
status=request.status,
|
||||
tags=request.tags,
|
||||
)
|
||||
use_case = UpdateVoiceLibraryUseCase(voice_repository)
|
||||
try:
|
||||
item = use_case.execute(command)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||
def delete_voice(
|
||||
voice_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
) -> Response:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = DeleteVoiceLibraryUseCase(voice_repository)
|
||||
deleted = use_case.execute(voice_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
||||
return Response(status_code=204)
|
||||
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
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")
|
||||
Executable → Regular
+13
-56
@@ -1,70 +1,33 @@
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "xiaoxia-saas"
|
||||
APP_VERSION: str = "0.1.61"
|
||||
APP_VERSION: str = "0.1.0"
|
||||
ENVIRONMENT: str = "development"
|
||||
DEBUG: bool = True
|
||||
|
||||
# 应用基础 URL,用于生成认证邮件中的链接
|
||||
# 开发环境默认 http://localhost:3000
|
||||
# 生产环境应通过环境变量 APP_BASE_URL 设置
|
||||
APP_BASE_URL: str = "http://localhost:3000"
|
||||
|
||||
# Container bind address; external expose is controlled by Docker/Nginx.
|
||||
API_HOST: str = "0.0.0.0" # nosec: B104
|
||||
# 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_URL: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
|
||||
DATABASE_POOL_SIZE: int = 20
|
||||
DATABASE_MAX_OVERFLOW: int = 10 # 调整为合理值:pool_size(20) + max_overflow(10) = 最大30连接
|
||||
DATABASE_MAX_OVERFLOW: int = 40
|
||||
DATABASE_POOL_TIMEOUT: int = 30
|
||||
DATABASE_POOL_RECYLE: int = 3600
|
||||
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_CONNECTION: int = 50
|
||||
REDIS_MAX_CONNECTIONS: int = 50
|
||||
ENABLE_REDIS_SESSIONS: bool = False
|
||||
|
||||
# JWT secret key - MUST be set via environment variable, no default allowed
|
||||
JWT_SECRET_KEY: Optional[str] = None
|
||||
# 旧的 JWT secret key(用于密钥轮换期间验证旧 token)
|
||||
# 在密钥轮换时,先设置新密钥,旧密钥保留在此处直到所有旧 token 过期
|
||||
JWT_SECRET_KEY_OLD: Optional[str] = None
|
||||
# 密钥轮换天数(到达此天数后建议更换密钥)
|
||||
SECRET_ROTATION_DAYS: int = 90
|
||||
|
||||
@field_validator("JWT_SECRET_KEY", mode="before")
|
||||
@classmethod
|
||||
def validate_jwt_secret_key(cls, v):
|
||||
if v is None or v == "":
|
||||
raise ValueError(
|
||||
"JWT_SECRET_KEY must be set via environment variable. "
|
||||
"Do not use default value in production!"
|
||||
)
|
||||
# Block known insecure default values
|
||||
insecure_defaults = [
|
||||
"your-secret-key-change-in-production",
|
||||
"your-secret-key",
|
||||
"secret",
|
||||
"changeme",
|
||||
"password",
|
||||
]
|
||||
if v.lower() in [d.lower() for d in insecure_defaults]:
|
||||
raise ValueError(
|
||||
f"JWT_SECRET_KEY '{v}' is insecure. "
|
||||
"Please set a strong random secret via environment variable."
|
||||
)
|
||||
return v
|
||||
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
|
||||
ENABLE_EMAIL_DELIVERY: bool = False
|
||||
SMTP_HOST: str = "smtp.gmail.com"
|
||||
@@ -78,18 +41,16 @@ class Settings(BaseSettings):
|
||||
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
|
||||
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
|
||||
|
||||
# OSS 七牛云相关
|
||||
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliiyuncs.com"
|
||||
# 阿里云 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_EXPRESS_SECRET: int = 900
|
||||
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS: int = 900
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
CORS_ORIGINS_RAW: str = (
|
||||
"http://localhost:3000,http://localhost:5173,http://localhost:8000"
|
||||
)
|
||||
CORS_ORIGINS_RAW: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
@@ -100,11 +61,7 @@ class Settings(BaseSettings):
|
||||
|
||||
@property
|
||||
def CORS_ORIGINS(self) -> list[str]:
|
||||
return [
|
||||
origin.strip()
|
||||
for origin in self.CORS_ORIGINS_RAW.split(",")
|
||||
if origin.strip()
|
||||
]
|
||||
return [origin.strip() for origin in self.CORS_ORIGINS_RAW.split(",") if origin.strip()]
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
|
||||
@@ -1,49 +1,34 @@
|
||||
"""Database session management and engine configuration.
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
统一使用 app.config 中的数据库配置,移除重复的 DatabaseSettings。
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator
|
||||
|
||||
from app.config import settings
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
engine = create_engine(
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""Dependency for getting database sessions."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
_settings: Optional[DatabaseSettings] = None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db_context() -> Generator[Session, None, None]:
|
||||
"""Context manager for database sessions.
|
||||
|
||||
Usage:
|
||||
with get_db_context() as db:
|
||||
db.query(Model).all()
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
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
|
||||
|
||||
@@ -211,6 +211,8 @@ class OSSStorageService:
|
||||
return self.bucket.object_exists(storage_key)
|
||||
|
||||
|
||||
# 向后兼容的服务类名
|
||||
MinIOService = OSSStorageService
|
||||
|
||||
|
||||
_storage_service = None
|
||||
@@ -223,3 +225,8 @@ def get_storage_service() -> OSSStorageService:
|
||||
_storage_service = OSSStorageService()
|
||||
return _storage_service
|
||||
|
||||
|
||||
# 向后兼容的别名
|
||||
def get_minio_service() -> OSSStorageService:
|
||||
"""向后兼容:返回 OSS 服务(已替换 MinIO)"""
|
||||
return get_storage_service()
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
"""Dependency injection providers for FastAPI endpoints.
|
||||
|
||||
All repository and service factories are defined here as FastAPI dependencies,
|
||||
ensuring proper lifecycle management and testability.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import redis
|
||||
from typing import Generator
|
||||
|
||||
from app.config import settings
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -21,21 +12,12 @@ from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRe
|
||||
from packages.adapters.sqlalchemy_impl.classification_job_repository import (
|
||||
SQLAlchemyClassificationJobRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.duplication_repository import (
|
||||
SQLAlchemyDuplicationRecordRepository,
|
||||
)
|
||||
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.title_library_repository import (
|
||||
SQLAlchemyTitleLibraryRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.voice_library_repository import (
|
||||
SQLAlchemyVoiceLibraryRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.ingest_job_repository import (
|
||||
SQLAlchemyIngestJobRepository,
|
||||
)
|
||||
@@ -44,23 +26,18 @@ from packages.adapters.sqlalchemy_impl.project_repository import (
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.session import build_session_factory
|
||||
from packages.adapters.sqlalchemy_impl.user_repository import SQLAlchemyUserRepository
|
||||
from packages.ports.asset_repository import AssetRepository
|
||||
from packages.ports.asset_library_repository import AssetLibraryRepository
|
||||
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.classification_job_repository import ClassificationJobRepository
|
||||
from packages.ports.duplication_repository import DuplicationRecordRepository
|
||||
from packages.ports.generation_task_repository import GenerationTaskRepository
|
||||
from packages.ports.title_library_repository import TitleLibraryRepository
|
||||
from packages.ports.voice_library_repository import VoiceLibraryRepository
|
||||
from packages.ports.generated_video_repository import GeneratedVideoRepository
|
||||
from packages.ports.ingest_job_repository import IngestJobRepository
|
||||
from packages.ports.project_repository import ProjectRepository
|
||||
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() -> Generator[Session, None, None]:
|
||||
"""Provide a database session with automatic cleanup."""
|
||||
def get_db_session():
|
||||
session: Session = _SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
@@ -71,76 +48,76 @@ def get_db_session() -> Generator[Session, None, None]:
|
||||
def get_asset_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyAssetRepository:
|
||||
"""Provide the SQLAlchemy asset repository implementation."""
|
||||
return SQLAlchemyAssetRepository(session)
|
||||
|
||||
|
||||
def get_asset_library_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyAssetLibraryRepository:
|
||||
"""Provide the SQLAlchemy asset library repository implementation."""
|
||||
return SQLAlchemyAssetLibraryRepository(session)
|
||||
|
||||
|
||||
def get_ingest_job_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyIngestJobRepository:
|
||||
"""Provide the SQLAlchemy ingest job repository implementation."""
|
||||
return SQLAlchemyIngestJobRepository(session)
|
||||
|
||||
|
||||
def get_classification_job_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyClassificationJobRepository:
|
||||
"""Provide the SQLAlchemy classification job repository implementation."""
|
||||
return SQLAlchemyClassificationJobRepository(session)
|
||||
|
||||
|
||||
def get_generation_task_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyGenerationTaskRepository:
|
||||
"""Provide the SQLAlchemy generation task repository implementation."""
|
||||
return SQLAlchemyGenerationTaskRepository(session)
|
||||
|
||||
|
||||
def get_generated_video_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyGeneratedVideoRepository:
|
||||
"""Provide the SQLAlchemy generated video repository implementation."""
|
||||
return SQLAlchemyGeneratedVideoRepository(session)
|
||||
|
||||
|
||||
def get_duplication_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyDuplicationRecordRepository:
|
||||
"""Provide the SQLAlchemy duplication record repository implementation."""
|
||||
return SQLAlchemyDuplicationRecordRepository(session)
|
||||
|
||||
|
||||
def get_project_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyProjectRepository:
|
||||
"""Provide the SQLAlchemy project repository implementation."""
|
||||
return SQLAlchemyProjectRepository(session)
|
||||
|
||||
|
||||
|
||||
def get_user_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> UserRepository:
|
||||
"""Provide the SQLAlchemy user repository implementation."""
|
||||
return SQLAlchemyUserRepository(session)
|
||||
|
||||
|
||||
def get_auth_session_store() -> SessionStore | NoopSessionStore:
|
||||
"""Provide the session store based on configuration."""
|
||||
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() -> NoopEmailService | EmailService:
|
||||
"""Provide the email service based on configuration."""
|
||||
def get_auth_email_service():
|
||||
if not settings.ENABLE_EMAIL_DELIVERY:
|
||||
return NoopEmailService()
|
||||
return get_email_service(
|
||||
@@ -155,16 +132,3 @@ def get_auth_email_service() -> NoopEmailService | EmailService:
|
||||
),
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
def get_title_library_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyTitleLibraryRepository:
|
||||
"""Provide the SQLAlchemy title library repository implementation."""
|
||||
return SQLAlchemyTitleLibraryRepository(session)
|
||||
|
||||
|
||||
def get_voice_library_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyVoiceLibraryRepository:
|
||||
"""Provide the SQLAlchemy voice library repository implementation."""
|
||||
return SQLAlchemyVoiceLibraryRepository(session)
|
||||
|
||||
@@ -9,12 +9,14 @@ 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
|
||||
from fastapi import Depends
|
||||
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)
|
||||
|
||||
@@ -36,3 +38,39 @@ async def get_current_user_optional(
|
||||
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
|
||||
|
||||
@@ -1,60 +1,15 @@
|
||||
"""请求日志和限流中间件"""
|
||||
"""
|
||||
请求日志中间件
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 敏感参数名称模式(不区分大小写)
|
||||
SENSITIVE_PARAM_PATTERNS = re.compile(
|
||||
r"^(password|passwd|pwd|token|secret|key|authorization|auth|api_key|" # noqa: E501
|
||||
r"apikey|access_token|refresh_token|accesstoken|refreshtoken|" # noqa: E501
|
||||
r"session_id|sessionid|sid|cookie|csrf|xsrf|bearer)$", # noqa: E501
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def filter_sensitive_params(query_string: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
过滤 query string 中的敏感参数
|
||||
|
||||
Args:
|
||||
query_string: 原始 query string,例如 "name=xxx&password=secret&token=abc"
|
||||
|
||||
Returns:
|
||||
过滤后的 query string,敏感参数的值被替换为 "***"
|
||||
如果 query_string 为空或 None,返回原始值
|
||||
"""
|
||||
if not query_string:
|
||||
return query_string
|
||||
|
||||
if query_string.startswith("?"):
|
||||
query_string = query_string[1:]
|
||||
|
||||
if not query_string:
|
||||
return query_string
|
||||
|
||||
parts = query_string.split("&")
|
||||
filtered_parts = []
|
||||
|
||||
for part in parts:
|
||||
if "=" in part:
|
||||
key, value = part.split("=", 1)
|
||||
if SENSITIVE_PARAM_PATTERNS.match(key):
|
||||
filtered_parts.append(f"{key}=***")
|
||||
else:
|
||||
filtered_parts.append(part)
|
||||
else:
|
||||
# 没有 = 的参数,保留原样
|
||||
filtered_parts.append(part)
|
||||
|
||||
return "&".join(filtered_parts)
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""请求日志中间件"""
|
||||
@@ -63,60 +18,47 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
# 记录请求开始时间
|
||||
start_time = time.time()
|
||||
|
||||
# 过滤 query string 中的敏感参数
|
||||
raw_query = str(request.url.query) if request.url.query else ""
|
||||
safe_query = filter_sensitive_params(raw_query)
|
||||
|
||||
# 记录请求信息(不包含敏感参数)
|
||||
if safe_query:
|
||||
logger.info(
|
||||
f"Request: {request.method} {request.url.path}?{safe_query}" # noqa: E501
|
||||
)
|
||||
else:
|
||||
logger.info(f"Request: {request.method} {request.url.path}")
|
||||
# 记录请求信息
|
||||
logger.info(f"Request: {request.method} {request.url.path}")
|
||||
|
||||
# 处理请求
|
||||
response = await call_next(request)
|
||||
|
||||
# 记录请求结束时间
|
||||
# 计算处理时间
|
||||
process_time = time.time() - start_time
|
||||
|
||||
# 记录响应信息
|
||||
logger.info(
|
||||
f"Response: {request.method} {request.url.path} "
|
||||
f"status={response.status_code} time={process_time:.3f}s"
|
||||
f"Response: {request.method} {request.url.path} " f"status={response.status_code} time={process_time:.3f}s"
|
||||
)
|
||||
|
||||
# 添加处理时间到响应头
|
||||
# 添加响应头
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""基于 IP 的简单限流中间件"""
|
||||
"""简单的速率限制中间件(基于内存)"""
|
||||
|
||||
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: [timestamps]}
|
||||
self.requests = {} # {ip: [(timestamp, ...)]}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# 获取客户端 IP
|
||||
client_ip = request.client.host
|
||||
|
||||
current_time = time.time()
|
||||
|
||||
# 清理过期记录
|
||||
if client_ip in self.requests:
|
||||
self.requests[client_ip] = [
|
||||
ts
|
||||
for ts in self.requests[client_ip]
|
||||
if current_time - ts < self.window_seconds
|
||||
ts for ts in self.requests[client_ip] if current_time - ts < self.window_seconds
|
||||
]
|
||||
|
||||
# 计算请求次数
|
||||
# 检查速率限制
|
||||
request_count = len(self.requests.get(client_ip, []))
|
||||
|
||||
if request_count >= self.max_requests:
|
||||
@@ -127,10 +69,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
content={
|
||||
"error": {
|
||||
"code": "RATE_LIMIT_EXCEEDED",
|
||||
"message": ( # noqa: E501
|
||||
f"Too many requests. Limit: "
|
||||
f"{self.max_requests} per {self.window_seconds}s"
|
||||
),
|
||||
"message": f"Too many requests. Limit: {self.max_requests} per {self.window_seconds}s",
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -143,10 +82,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
# 处理请求
|
||||
response = await call_next(request)
|
||||
|
||||
# 添加限流信息到响应头
|
||||
# 添加速率限制信息到响应头
|
||||
response.headers["X-RateLimit-Limit"] = str(self.max_requests)
|
||||
response.headers["X-RateLimit-Remaining"] = str(
|
||||
self.max_requests - len(self.requests[client_ip])
|
||||
)
|
||||
response.headers["X-RateLimit-Remaining"] = str(self.max_requests - len(self.requests[client_ip]))
|
||||
|
||||
return response
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
"""
|
||||
Prometheus metrics middleware and metrics endpoint for the API.
|
||||
|
||||
Exposes:
|
||||
- HTTP request duration histogram (by method, path, status)
|
||||
- HTTP requests total counter (by method, path, status)
|
||||
- Requests in progress gauge
|
||||
- Custom application metrics
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from prometheus_client import (
|
||||
Counter,
|
||||
Gauge,
|
||||
Histogram,
|
||||
generate_latest,
|
||||
CONTENT_TYPE_LATEST,
|
||||
REGISTRY,
|
||||
)
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import PlainTextResponse
|
||||
|
||||
|
||||
# Buckets for HTTP request duration (seconds)
|
||||
HTTP_DURATION_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
|
||||
|
||||
# --- Metrics Definitions ---
|
||||
|
||||
HTTP_REQUESTS_TOTAL = Counter(
|
||||
"http_requests_total",
|
||||
"Total HTTP requests",
|
||||
["method", "endpoint", "status"],
|
||||
registry=REGISTRY,
|
||||
)
|
||||
|
||||
HTTP_REQUEST_DURATION_SECONDS = Histogram(
|
||||
"http_request_duration_seconds",
|
||||
"HTTP request duration in seconds",
|
||||
["method", "endpoint", "status"],
|
||||
buckets=HTTP_DURATION_BUCKETS,
|
||||
registry=REGISTRY,
|
||||
)
|
||||
|
||||
HTTP_REQUESTS_IN_PROGRESS = Gauge(
|
||||
"http_requests_in_progress",
|
||||
"Number of HTTP requests currently being processed",
|
||||
["method"],
|
||||
registry=REGISTRY,
|
||||
)
|
||||
|
||||
# --- Application Metrics ---
|
||||
|
||||
APP_INFO = Gauge(
|
||||
"app_info",
|
||||
"Application information",
|
||||
["version", "environment"],
|
||||
registry=REGISTRY,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
"""
|
||||
Normalize request path to reduce cardinality.
|
||||
Replace UUIDs and numeric IDs with placeholders.
|
||||
"""
|
||||
# Replace UUIDs
|
||||
path = re.sub(
|
||||
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
|
||||
":uuid",
|
||||
path,
|
||||
)
|
||||
# Replace numeric IDs in path segments
|
||||
path = re.sub(r"/\d+(/|$)", "/:id\\1", path)
|
||||
return path
|
||||
|
||||
|
||||
class PrometheusMetricsMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware that records Prometheus metrics for every HTTP request."""
|
||||
|
||||
def __init__(self, app, exclude_paths: list[str] | None = None):
|
||||
super().__init__(app)
|
||||
self.exclude_paths = set(exclude_paths or ["/metrics", "/health", "/ready", "/startup"])
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
path = request.url.path
|
||||
if path in self.exclude_paths:
|
||||
return await call_next(request)
|
||||
|
||||
method = request.method
|
||||
normalized_path = _normalize_path(path)
|
||||
|
||||
HTTP_REQUESTS_IN_PROGRESS.labels(method=method).inc()
|
||||
start_time = time.perf_counter()
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status = str(response.status_code)
|
||||
return response
|
||||
except Exception:
|
||||
status = "500"
|
||||
raise
|
||||
finally:
|
||||
duration = time.perf_counter() - start_time
|
||||
|
||||
HTTP_REQUESTS_TOTAL.labels(
|
||||
method=method,
|
||||
endpoint=normalized_path,
|
||||
status=status,
|
||||
).inc()
|
||||
|
||||
HTTP_REQUEST_DURATION_SECONDS.labels(
|
||||
method=method,
|
||||
endpoint=normalized_path,
|
||||
status=status,
|
||||
).observe(duration)
|
||||
|
||||
HTTP_REQUESTS_IN_PROGRESS.labels(method=method).dec()
|
||||
|
||||
|
||||
async def metrics_endpoint(request: Request) -> PlainTextResponse:
|
||||
"""FastAPI endpoint that returns Prometheus metrics in text format."""
|
||||
import os
|
||||
version = os.getenv("APP_VERSION", "unknown")
|
||||
environment = os.getenv("APP_ENV", "unknown")
|
||||
APP_INFO.labels(version=version, environment=environment).set(1)
|
||||
|
||||
metrics_output = generate_latest(REGISTRY)
|
||||
return PlainTextResponse(
|
||||
content=metrics_output,
|
||||
media_type=CONTENT_TYPE_LATEST,
|
||||
)
|
||||
@@ -2,6 +2,7 @@ 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)
|
||||
@@ -21,12 +22,9 @@ class CreateAssetRequest(BaseModel):
|
||||
uploaded_by_user_id: str = Field(default="", max_length=100)
|
||||
|
||||
|
||||
class UpdateAssetReviewRequest(BaseModel):
|
||||
review_status: str = Field(..., pattern="^(pending_review|approved|rejected)$")
|
||||
|
||||
|
||||
class AssetResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
library_id: str
|
||||
name: str
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AssetSmartViewItem(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
count: int
|
||||
description: str
|
||||
|
||||
|
||||
class AssetGapItem(BaseModel):
|
||||
key: str
|
||||
severity: str
|
||||
message: str
|
||||
recommendation: str
|
||||
|
||||
|
||||
class ProjectAssetDiagnosisResponse(BaseModel):
|
||||
project_id: str
|
||||
readiness_score: int
|
||||
readiness_label: str
|
||||
total_assets: int
|
||||
ready_assets: int
|
||||
video_assets: int
|
||||
image_assets: int
|
||||
voice_assets: int
|
||||
total_duration_seconds: float
|
||||
estimated_video_count: int
|
||||
used_assets: int
|
||||
unused_assets: int
|
||||
pending_review_assets: int
|
||||
smart_views: list[AssetSmartViewItem]
|
||||
gaps: list[AssetGapItem]
|
||||
@@ -2,6 +2,7 @@ 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)$")
|
||||
@@ -9,6 +10,7 @@ class CreateAssetLibraryRequest(BaseModel):
|
||||
|
||||
class AssetLibraryResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
name: str
|
||||
kind: str
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ChunkedUploadInitRequest(BaseModel):
|
||||
filename: str = Field(..., min_length=1, max_length=255, description="Filename")
|
||||
file_size: int = Field(..., gt=0, le=2147483648, description="File size in bytes, max 2GB")
|
||||
total_chunks: int = Field(..., gt=0, description="Total number of chunks")
|
||||
content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100, description="Content type")
|
||||
project_id: str = Field(..., min_length=1, description="Project ID")
|
||||
library_id: str = Field(..., min_length=1, description="Asset library ID")
|
||||
|
||||
|
||||
class ChunkedUploadInitResponse(BaseModel):
|
||||
upload_id: str = Field(..., description="Upload ID")
|
||||
chunk_size: int = Field(..., description="Chunk size in bytes")
|
||||
total_chunks: int = Field(..., description="Total number of chunks")
|
||||
filename: str = Field(..., description="Filename")
|
||||
expires_at: datetime = Field(..., description="Expiration time")
|
||||
|
||||
|
||||
class ChunkedUploadStatusResponse(BaseModel):
|
||||
upload_id: str = Field(..., description="Upload ID")
|
||||
filename: str = Field(..., description="Filename")
|
||||
file_size: int = Field(..., description="File size")
|
||||
total_chunks: int = Field(..., description="Total number of chunks")
|
||||
uploaded_chunks: list[int] = Field(..., description="List of uploaded chunk indices")
|
||||
status: str = Field(..., description="Upload status: pending/uploading/completed/failed")
|
||||
created_at: datetime = Field(..., description="Creation time")
|
||||
expires_at: datetime = Field(..., description="Expiration time")
|
||||
|
||||
|
||||
class ChunkedUploadCompleteRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1, description="Project ID")
|
||||
library_id: str = Field(..., min_length=1, description="Asset library ID")
|
||||
|
||||
|
||||
class ChunkedUploadCompleteResponse(BaseModel):
|
||||
storage_key: str = Field(..., description="Storage key")
|
||||
ingest_job_id: str = Field(..., description="Ingest job ID")
|
||||
url: str = Field(..., description="File URL")
|
||||
@@ -2,12 +2,14 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SubmitClassificationJobRequest(BaseModel):
|
||||
workspace_id: str = Field(..., min_length=1)
|
||||
project_id: str = Field(..., min_length=1)
|
||||
asset_id: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class ClassificationJobResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
asset_id: str
|
||||
status: str
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""查重 API Pydantic schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DuplicateSegmentResponse(BaseModel):
|
||||
"""重复片段响应。"""
|
||||
|
||||
id: str
|
||||
source_start: float
|
||||
source_end: float
|
||||
matched_video_id: str
|
||||
matched_video_name: str
|
||||
matched_start: float
|
||||
matched_end: float
|
||||
similarity: float
|
||||
|
||||
|
||||
class DuplicationRecordResponse(BaseModel):
|
||||
"""查重记录响应(列表项)。"""
|
||||
|
||||
id: str
|
||||
filename: str
|
||||
file_size: int
|
||||
duration_seconds: float = 0.0
|
||||
status: str = "pending"
|
||||
duplicate_rate: float | None = None
|
||||
duplicate_count: int = 0
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class DuplicationDetailResponse(DuplicationRecordResponse):
|
||||
"""查重详情响应(含重复片段)。"""
|
||||
|
||||
segments: list[DuplicateSegmentResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DuplicationUploadResponse(BaseModel):
|
||||
"""上传查重响应。"""
|
||||
|
||||
id: str
|
||||
status: str
|
||||
message: str
|
||||
@@ -1,16 +1,9 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
GeneratedVideoReviewStatus = Literal["pending_review", "approved", "rejected"]
|
||||
|
||||
|
||||
class UpdateGeneratedVideoReviewRequest(BaseModel):
|
||||
review_status: GeneratedVideoReviewStatus
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class GeneratedVideoResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
generation_task_id: str
|
||||
name: str
|
||||
@@ -21,10 +14,6 @@ class GeneratedVideoResponse(BaseModel):
|
||||
width: int
|
||||
height: int
|
||||
fps: float
|
||||
status: str = "completed"
|
||||
review_status: str = "pending_review"
|
||||
generation_params: dict = Field(default_factory=dict)
|
||||
download_url: str | None = None
|
||||
|
||||
|
||||
class GeneratedVideoDownloadUrlResponse(BaseModel):
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 = ""
|
||||
@@ -11,6 +12,7 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
|
||||
class GenerationTaskResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
asset_library_id: str
|
||||
strategy_id: str
|
||||
|
||||
@@ -2,6 +2,7 @@ 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)
|
||||
@@ -9,6 +10,7 @@ class SubmitIngestJobRequest(BaseModel):
|
||||
|
||||
class IngestJobResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
library_id: str
|
||||
storage_key: str
|
||||
|
||||
@@ -2,16 +2,16 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateProjectRequest(BaseModel):
|
||||
workspace_id: str = Field(..., min_length=1)
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: str = Field(default="", max_length=500)
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
id: str
|
||||
owner_user_id: str
|
||||
workspace_id: str
|
||||
name: str
|
||||
description: str
|
||||
shared_users: list[str] = []
|
||||
|
||||
|
||||
class ListProjectsResponse(BaseModel):
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Subscription schemas for API request/response models."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ============ Enums / Types ============
|
||||
|
||||
class PlanType(str):
|
||||
"""套餐类型"""
|
||||
FREE = "free"
|
||||
STANDARD = "standard"
|
||||
PRO = "pro"
|
||||
ENTERPRISE = "enterprise"
|
||||
|
||||
|
||||
class SubscriptionStatus(str):
|
||||
"""订阅状态"""
|
||||
ACTIVE = "active"
|
||||
EXPIRED = "expired"
|
||||
CANCELLED = "cancelled"
|
||||
TRIAL = "trial"
|
||||
|
||||
|
||||
class BillingStatus(str):
|
||||
"""账单状态"""
|
||||
PAID = "paid"
|
||||
PENDING = "pending"
|
||||
FAILED = "failed"
|
||||
REFUNDED = "refunded"
|
||||
|
||||
|
||||
class BillingCycle(str):
|
||||
"""计费周期"""
|
||||
MONTHLY = "monthly"
|
||||
YEARLY = "yearly"
|
||||
|
||||
|
||||
# ============ Response Schemas ============
|
||||
|
||||
class SubscriptionInfo(BaseModel):
|
||||
"""当前订阅信息"""
|
||||
id: str
|
||||
plan_id: str
|
||||
plan_name: str
|
||||
status: str
|
||||
billing_cycle: str
|
||||
current_period_start: str
|
||||
current_period_end: str
|
||||
amount: float
|
||||
auto_renew: bool
|
||||
created_at: str
|
||||
|
||||
|
||||
class BillingRecord(BaseModel):
|
||||
"""账单记录"""
|
||||
id: str
|
||||
plan_name: str
|
||||
amount: float
|
||||
billing_cycle: str
|
||||
status: str
|
||||
payment_method: str
|
||||
created_at: str
|
||||
invoice_url: Optional[str] = None
|
||||
|
||||
|
||||
class ChangePlanResponse(BaseModel):
|
||||
"""升级/降级响应"""
|
||||
success: bool
|
||||
message: str
|
||||
new_subscription: Optional[SubscriptionInfo] = None
|
||||
|
||||
|
||||
class SimpleResponse(BaseModel):
|
||||
"""简单响应(用于取消订阅、切换自动续费等)"""
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
# ============ Request Schemas ============
|
||||
|
||||
class ChangePlanRequest(BaseModel):
|
||||
"""升级/降级请求"""
|
||||
target_plan_id: str = Field(..., description="目标套餐ID")
|
||||
billing_cycle: str = Field(..., description="计费周期: monthly/yearly")
|
||||
|
||||
|
||||
class ToggleAutoRenewRequest(BaseModel):
|
||||
"""切换自动续费请求"""
|
||||
enabled: bool = Field(..., description="是否开启自动续费")
|
||||
@@ -1,22 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ProjectTaskResponse(BaseModel):
|
||||
id: str
|
||||
task_type: str
|
||||
project_id: str
|
||||
status: str
|
||||
progress: float
|
||||
current_step: str
|
||||
error_message: str = ""
|
||||
user_message: str = ""
|
||||
retryable: bool = False
|
||||
source_id: str = ""
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class ListProjectTasksResponse(BaseModel):
|
||||
items: list[ProjectTaskResponse] = Field(default_factory=list)
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Title library Pydantic schemas."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TitleLibraryItemResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
text: str
|
||||
category: str = "default"
|
||||
description: str = ""
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
usage_count: int = 0
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ListTitleLibraryResponse(BaseModel):
|
||||
items: list[TitleLibraryItemResponse]
|
||||
total: int = 0
|
||||
|
||||
|
||||
class CreateTitleLibraryRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
text: str = Field(..., min_length=1, max_length=500)
|
||||
category: str = "default"
|
||||
description: str = ""
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateTitleLibraryRequest(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
text: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||
category: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
@@ -1,12 +1,6 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
||||
class UploadAssetRequest(BaseModel):
|
||||
"""素材上传请求(multipart form)"""
|
||||
project_id: str = Field(..., min_length=1, description="项目 ID")
|
||||
library_id: str = Field(..., min_length=1, description="素材库 ID")
|
||||
|
||||
class UploadAssetResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
@@ -14,6 +8,7 @@ class UploadAssetResponse(BaseModel):
|
||||
|
||||
|
||||
class DirectUploadPrepareRequest(BaseModel):
|
||||
workspace_id: str = Field(..., min_length=1)
|
||||
project_id: str = Field(..., min_length=1)
|
||||
library_id: str = Field(..., min_length=1)
|
||||
filename: str = Field(..., min_length=1, max_length=255)
|
||||
@@ -31,6 +26,7 @@ class DirectUploadPrepareResponse(BaseModel):
|
||||
|
||||
|
||||
class DirectUploadCompleteRequest(BaseModel):
|
||||
workspace_id: str = Field(..., min_length=1)
|
||||
project_id: str = Field(..., min_length=1)
|
||||
library_id: str = Field(..., min_length=1)
|
||||
storage_key: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Voice library Pydantic schemas."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class VoiceLibraryItemResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
text: str = ""
|
||||
voice_provider: str = ""
|
||||
voice_id: str = ""
|
||||
voice_name: str = ""
|
||||
audio_url: str = ""
|
||||
duration: float = 0
|
||||
file_size: int = 0
|
||||
status: str = "completed"
|
||||
project_id: Optional[str] = None
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ListVoiceLibraryResponse(BaseModel):
|
||||
items: list[VoiceLibraryItemResponse]
|
||||
total: int = 0
|
||||
|
||||
|
||||
class CreateVoiceLibraryRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
text: str = ""
|
||||
voice_provider: str = ""
|
||||
voice_id: str = ""
|
||||
voice_name: str = ""
|
||||
audio_url: str = ""
|
||||
duration: float = 0
|
||||
file_size: int = 0
|
||||
status: str = "completed"
|
||||
project_id: Optional[str] = None
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateVoiceLibraryRequest(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
text: Optional[str] = None
|
||||
voice_provider: Optional[str] = None
|
||||
voice_id: Optional[str] = None
|
||||
voice_name: Optional[str] = None
|
||||
audio_url: Optional[str] = None
|
||||
duration: Optional[float] = None
|
||||
file_size: Optional[int] = None
|
||||
status: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
+3
-24
@@ -10,7 +10,6 @@ from app.middleware.exceptions import (
|
||||
validation_exception_handler,
|
||||
)
|
||||
from app.middleware.logging import RequestLoggingMiddleware
|
||||
from app.middleware.prometheus_metrics import PrometheusMetricsMiddleware, metrics_endpoint
|
||||
from fastapi import FastAPI
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -24,7 +23,6 @@ app = FastAPI(
|
||||
version=settings.APP_VERSION,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
redirect_slashes=False,
|
||||
)
|
||||
|
||||
app.add_exception_handler(APIException, api_exception_handler)
|
||||
@@ -32,32 +30,17 @@ app.add_exception_handler(StarletteHTTPException, http_exception_handler)
|
||||
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||
app.add_exception_handler(Exception, general_exception_handler)
|
||||
|
||||
# P1-1: Fix CORS configuration security issue
|
||||
# - allow_credentials=True is incompatible with allow_origins=["*"]
|
||||
# - In production, only allow configured domains, not "*"
|
||||
if settings.DEBUG:
|
||||
allow_origins = settings.CORS_ORIGINS # Allow localhost in debug mode
|
||||
else:
|
||||
# In production, filter out any wildcard "*" origins
|
||||
allow_origins = list({origin for origin in settings.CORS_ORIGINS if origin != "*"})
|
||||
# Always ensure production domains are included
|
||||
for domain in ("https://xiaoxiajianji.com", "https://saas.xiaoxiajianji.com"):
|
||||
if domain not in allow_origins:
|
||||
allow_origins.append(domain)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=allow_origins,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
app.add_middleware(PrometheusMetricsMiddleware)
|
||||
|
||||
app.include_router(health_router)
|
||||
app.add_route("/metrics", metrics_endpoint, methods=["GET"])
|
||||
app.include_router(api_router)
|
||||
|
||||
_generated_files_dir = os.getenv("GENERATED_FILES_DIR", "/app/generated")
|
||||
@@ -69,10 +52,6 @@ app.mount(
|
||||
)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
return app
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const PASSWORD = 'SmokePass123!';
|
||||
const apiBase = process.env.E2E_API_BASE || '/api/v1';
|
||||
const apiOrigin = apiBase.endsWith('/api/v1') ? apiBase.slice(0, -'/api/v1'.length) : '';
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import('@playwright/test').Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}` });
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
type WorkspaceResponse = { id?: string; workspace_id?: string };
|
||||
type ProjectResponse = { id: string };
|
||||
type LibraryResponse = { id: string };
|
||||
type AssetListResponse = { items: Array<{ name: string; status: string; mime_type?: string; file_type?: string }> };
|
||||
type GenerationTaskResponse = { id: string; status: string; progress: number; result_count: number; error_message?: string | null; strategy_id?: string | null; edit_plan_id?: string | null };
|
||||
type ProjectTitleResponse = { id: string; text: string; usage_count: number };
|
||||
type GeneratedVideoResponse = { id: string; name: string; file_url: string; file_size: number };
|
||||
|
||||
test.describe('Core generation and download flow', () => {
|
||||
test('generates an MP4 from the browser and exposes a playable download', async ({ page, request }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-generation-${suffix}@example.com`;
|
||||
const username = `e2e_generation_${suffix}`;
|
||||
const libraryName = `E2E Generation Library ${suffix}`;
|
||||
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, username, password: PASSWORD, display_name: username },
|
||||
});
|
||||
expect(register.status(), await register.text()).toBe(201);
|
||||
|
||||
const registerData = (await register.json()) as { user_id: string };
|
||||
|
||||
const login = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password: PASSWORD },
|
||||
});
|
||||
expect(login.status(), await login.text()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
|
||||
const workspace = await request.post(`${apiBase}/workspaces`, {
|
||||
headers,
|
||||
data: { name: `E2E Generation Workspace ${suffix}` },
|
||||
});
|
||||
expect(workspace.status(), await workspace.text()).toBe(201);
|
||||
const workspaceData = (await workspace.json()) as WorkspaceResponse;
|
||||
const workspaceId = workspaceData.id || workspaceData.workspace_id;
|
||||
expect(workspaceId).toBeTruthy();
|
||||
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { workspace_id: workspaceId, name: `E2E Generation Project ${suffix}` },
|
||||
});
|
||||
expect(project.status(), await project.text()).toBe(200);
|
||||
const projectData = (await project.json()) as ProjectResponse;
|
||||
|
||||
const library = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: { workspace_id: workspaceId, project_id: projectData.id, name: libraryName, kind: 'video' },
|
||||
});
|
||||
expect(library.status(), await library.text()).toBe(200);
|
||||
const libraryData = (await library.json()) as LibraryResponse;
|
||||
|
||||
const projectTitleText = `E2E 生成标题 ${suffix}`;
|
||||
const title = await request.post(`${apiBase}/projects/${projectData.id}/titles`, {
|
||||
headers,
|
||||
data: { workspace_id: workspaceId, text: projectTitleText, category: 'marketing', favorite: true },
|
||||
});
|
||||
expect(title.status(), await title.text()).toBe(200);
|
||||
const titleData = (await title.json()) as ProjectTitleResponse;
|
||||
|
||||
const fixture = fs.readFileSync(path.join(currentDir, 'fixtures', 'sample.mp4'));
|
||||
const upload = await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
workspace_id: workspaceId || '',
|
||||
project_id: projectData.id,
|
||||
library_id: libraryData.id,
|
||||
file: {
|
||||
name: 'e2e-generation-source.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
buffer: fixture,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(upload.status(), await upload.text()).toBe(200);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const assets = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libraryData.id },
|
||||
});
|
||||
if (!assets.ok()) {
|
||||
return `http_${assets.status()}`;
|
||||
}
|
||||
const data = (await assets.json()) as AssetListResponse;
|
||||
const asset = data.items.find((item) => item.name === 'e2e-generation-source.mp4');
|
||||
return asset ? `${asset.mime_type || asset.file_type || ''}:${asset.status}` : 'missing';
|
||||
},
|
||||
{ timeout: 90_000, intervals: [1_000, 2_000, 3_000, 5_000] }
|
||||
)
|
||||
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/);
|
||||
|
||||
await page.addInitScript(
|
||||
({ token, user, projectId, workspaceId }) => {
|
||||
localStorage.setItem('access_token', token);
|
||||
localStorage.setItem('auth-storage', JSON.stringify({ state: { user, isAuthenticated: true }, version: 0 }));
|
||||
sessionStorage.setItem(`project-workspace:${projectId}`, workspaceId);
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
projectId: projectData.id,
|
||||
workspaceId,
|
||||
user: {
|
||||
id: registerData.user_id,
|
||||
user_id: registerData.user_id,
|
||||
email,
|
||||
username,
|
||||
display_name: username,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await page.goto(`/projects/${projectData.id}/generation`);
|
||||
await expect(page.getByText('剪辑参数')).toBeVisible({ timeout: 20_000 });
|
||||
await page.locator('.ant-select-selector').first().click();
|
||||
await page.getByText(`${libraryName} (video)`).click();
|
||||
await page.locator('.ant-select-selector').nth(1).click();
|
||||
await page.getByText(projectTitleText).click();
|
||||
|
||||
await expect(page.getByText(/素材就绪度:/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole('button', { name: '重新生成计划' })).toBeEnabled({ timeout: 20_000 });
|
||||
await page.getByRole('button', { name: '重新生成计划' }).click();
|
||||
await expect(page.getByText('剪辑计划预览')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/自动选择/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/e2e-generation-source\.mp4/)).toBeVisible({ timeout: 20_000 });
|
||||
const confirmGenerationButton = page.getByRole('complementary').getByRole('button', { name: '确认计划并生成' });
|
||||
await expect(confirmGenerationButton).toBeEnabled({ timeout: 20_000 });
|
||||
|
||||
const createTaskResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes('/api/v1/generation/tasks') && response.request().method() === 'POST',
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
await confirmGenerationButton.click();
|
||||
const createTaskResponse = await createTaskResponsePromise;
|
||||
expect(createTaskResponse.status(), await createTaskResponse.text()).toBe(200);
|
||||
const createdTask = (await createTaskResponse.json()) as GenerationTaskResponse;
|
||||
expect(createdTask.edit_plan_id || '').not.toBe('');
|
||||
|
||||
await expect(page.getByText(/生成状态:生成完成/)).toBeVisible({ timeout: 90_000 });
|
||||
await expect(page.getByText(/生成失败|生成任务加载失败|生成结果加载失败/)).toHaveCount(0);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const task = await request.get(`${apiBase}/generation/tasks/${createdTask.id}`, { headers });
|
||||
if (!task.ok()) {
|
||||
return `http_${task.status()}`;
|
||||
}
|
||||
const data = (await task.json()) as GenerationTaskResponse;
|
||||
return `${data.status}:${data.result_count}:${data.strategy_id || ''}:${data.error_message || ''}`;
|
||||
},
|
||||
{ timeout: 90_000, intervals: [1_000, 2_000, 5_000] }
|
||||
)
|
||||
.toMatch(new RegExp(`^completed:[1-9]\\d*:${titleData.id}:`));
|
||||
|
||||
const results = await request.get(`${apiBase}/generation/tasks/${createdTask.id}/results`, { headers });
|
||||
expect(results.status(), await results.text()).toBe(200);
|
||||
const resultsData = (await results.json()) as { items: GeneratedVideoResponse[] };
|
||||
expect(resultsData.items.length).toBeGreaterThan(0);
|
||||
const generatedVideo = resultsData.items[0];
|
||||
expect(generatedVideo.name).toMatch(/\.mp4$/);
|
||||
expect(generatedVideo.file_size).toBeGreaterThan(0);
|
||||
|
||||
await page.goto(`/projects/${projectData.id}/results`);
|
||||
const resultCard = page.locator('.xx-vertical-card').filter({ hasText: generatedVideo.name });
|
||||
await expect(resultCard).toBeVisible({ timeout: 20_000 });
|
||||
await expect(resultCard.getByText('待复核')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(resultCard.getByRole('button', { name: /下载/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '批量获取下载地址' })).toBeEnabled();
|
||||
await resultCard.getByRole('button', { name: '可发布' }).click();
|
||||
await expect(page.getByText('成片复核状态已更新')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(resultCard.locator('.xx-pill.ok', { hasText: '可发布' })).toBeVisible({ timeout: 20_000 });
|
||||
const reviewedVideo = await request.get(`${apiBase}/generated-videos/${generatedVideo.id}`, { headers });
|
||||
expect(reviewedVideo.status(), await reviewedVideo.text()).toBe(200);
|
||||
const reviewedVideoData = (await reviewedVideo.json()) as { review_status: string; generation_params: Record<string, unknown> };
|
||||
expect(reviewedVideoData.review_status).toBe('approved');
|
||||
expect(reviewedVideoData.generation_params.title_id).toBe(titleData.id);
|
||||
expect(reviewedVideoData.generation_params.edit_plan_id).toBe(createdTask.edit_plan_id);
|
||||
const downloadUrlResponse = await request.get(`${apiBase}/generated-videos/${generatedVideo.id}/download-url`, { headers });
|
||||
expect(downloadUrlResponse.status(), await downloadUrlResponse.text()).toBe(200);
|
||||
const downloadData = (await downloadUrlResponse.json()) as { download_url: string };
|
||||
const videoResponse = await request.get(downloadData.download_url, { timeout: 30_000 });
|
||||
expect(videoResponse.status(), await videoResponse.text()).toBe(200);
|
||||
expect(videoResponse.headers()['content-type'] || '').toContain('video/mp4');
|
||||
const videoBody = await videoResponse.body();
|
||||
expect(videoBody.length).toBeGreaterThan(1024);
|
||||
|
||||
const assetsAfterGeneration = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libraryData.id },
|
||||
});
|
||||
expect(assetsAfterGeneration.status(), await assetsAfterGeneration.text()).toBe(200);
|
||||
const assetsAfterGenerationData = (await assetsAfterGeneration.json()) as { items: Array<{ name: string; metadata: Record<string, unknown> }> };
|
||||
const sourceAsset = assetsAfterGenerationData.items.find((item) => item.name === 'e2e-generation-source.mp4');
|
||||
expect(sourceAsset?.metadata.generation_use_count).toBe(1);
|
||||
expect(sourceAsset?.metadata.review_status).toBe('pending_review');
|
||||
const titleAfterGeneration = await request.get(`${apiBase}/projects/${projectData.id}/titles`, { headers });
|
||||
expect(titleAfterGeneration.status(), await titleAfterGeneration.text()).toBe(200);
|
||||
const titlesData = (await titleAfterGeneration.json()) as { items: ProjectTitleResponse[] };
|
||||
expect(titlesData.items.find((item) => item.id === titleData.id)?.usage_count).toBe(1);
|
||||
|
||||
await page.goto(`/projects/${projectData.id}/tasks`);
|
||||
await expect(page.getByText('项目任务中心')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText('视频生成')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText('已完成').first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(createdTask.id)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const PASSWORD = 'SmokePass123!';
|
||||
const apiBase = process.env.E2E_API_BASE || '/api/v1';
|
||||
const apiOrigin = apiBase.endsWith('/api/v1') ? apiBase.slice(0, -'/api/v1'.length) : '';
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import('@playwright/test').Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}` });
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('Project title library flow', () => {
|
||||
test('creates a reusable title from the browser', async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-title-${suffix}@example.com`;
|
||||
const username = `e2e_title_${suffix}`;
|
||||
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, username, password: PASSWORD, display_name: username },
|
||||
});
|
||||
expect(register.status(), await register.text()).toBe(201);
|
||||
|
||||
const registerData = (await register.json()) as { user_id: string };
|
||||
|
||||
const login = await request.post(`${apiBase}/auth/login`, { data: { email, password: PASSWORD } });
|
||||
expect(login.status(), await login.text()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
|
||||
const workspace = await request.post(`${apiBase}/workspaces`, {
|
||||
headers,
|
||||
data: { name: `E2E Title Workspace ${suffix}` },
|
||||
});
|
||||
expect(workspace.status(), await workspace.text()).toBe(201);
|
||||
const workspaceData = (await workspace.json()) as { id?: string; workspace_id?: string };
|
||||
const workspaceId = workspaceData.id || workspaceData.workspace_id;
|
||||
expect(workspaceId).toBeTruthy();
|
||||
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { workspace_id: workspaceId, name: `E2E Title Project ${suffix}`, description: 'Playwright title smoke' },
|
||||
});
|
||||
expect(project.status(), await project.text()).toBe(200);
|
||||
const projectData = (await project.json()) as { id: string };
|
||||
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem('access_token', token);
|
||||
localStorage.setItem('auth-storage', JSON.stringify({ state: { user, isAuthenticated: true }, version: 0 }));
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
user: {
|
||||
id: registerData.user_id,
|
||||
user_id: registerData.user_id,
|
||||
email,
|
||||
username,
|
||||
display_name: username,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await page.goto(`/projects/${projectData.id}/titles`);
|
||||
await expect(page.getByRole('heading', { name: '标题库' })).toBeVisible({ timeout: 20_000 });
|
||||
const titleText = `E2E 标题 ${suffix}`;
|
||||
await page.getByPlaceholder('例如:3 秒抓住注意力,30 秒讲清卖点').fill(titleText);
|
||||
const title = await request.post(`${apiBase}/projects/${projectData.id}/titles`, {
|
||||
headers,
|
||||
data: { workspace_id: workspaceId, text: titleText, category: 'default', favorite: true },
|
||||
});
|
||||
expect(title.status(), await title.text()).toBe(200);
|
||||
await page.reload();
|
||||
await expect(page.getByText(titleText)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator('.xx-title-row').filter({ hasText: titleText }).getByText('常用').first()).toBeVisible();
|
||||
await page.getByPlaceholder('搜索标题').fill(titleText);
|
||||
await expect(page.getByText(titleText)).toBeVisible();
|
||||
await expect(page.getByText('使用次数:0')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const PASSWORD = 'SmokePass123!';
|
||||
const apiBase = process.env.E2E_API_BASE || '/api/v1';
|
||||
const apiOrigin = apiBase.endsWith('/api/v1') ? apiBase.slice(0, -'/api/v1'.length) : '';
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: import('@playwright/test').Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}` });
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
type WorkspaceResponse = { id?: string; workspace_id?: string };
|
||||
type ProjectResponse = { id: string };
|
||||
@@ -21,10 +10,10 @@ test.describe('Core media upload flow', () => {
|
||||
test('uploads a MOV asset from the browser and shows it as ready', async ({ page, request }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-mov-${suffix}@example.com`;
|
||||
const username = `e2e_mov_${suffix}`;
|
||||
const apiBase = '/api/v1';
|
||||
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: {
|
||||
@@ -36,8 +25,6 @@ test.describe('Core media upload flow', () => {
|
||||
});
|
||||
expect(register.status(), await register.text()).toBe(201);
|
||||
|
||||
const registerData = (await register.json()) as { user_id: string };
|
||||
|
||||
const login = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password: PASSWORD },
|
||||
});
|
||||
@@ -77,45 +64,28 @@ test.describe('Core media upload flow', () => {
|
||||
expect(library.status(), await library.text()).toBe(200);
|
||||
const libraryData = (await library.json()) as LibraryResponse;
|
||||
|
||||
await page.addInitScript(
|
||||
({ token, user, projectId, workspaceId }) => {
|
||||
localStorage.setItem('access_token', token);
|
||||
localStorage.setItem('auth-storage', JSON.stringify({ state: { user, isAuthenticated: true }, version: 0 }));
|
||||
sessionStorage.setItem(`project-workspace:${projectId}`, workspaceId);
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
projectId: projectData.id,
|
||||
workspaceId,
|
||||
user: {
|
||||
id: registerData.user_id,
|
||||
user_id: registerData.user_id,
|
||||
email,
|
||||
username,
|
||||
display_name: username,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
await page.goto('/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`);
|
||||
await expect(page.getByText('点击或拖拽素材到这里上传')).toBeEnabled({ timeout: 20_000 });
|
||||
|
||||
const upload = await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
workspace_id: workspaceId || '',
|
||||
project_id: projectData.id,
|
||||
library_id: libraryData.id,
|
||||
file: {
|
||||
name: 'e2e-sample.MOV',
|
||||
mimeType: 'video/quicktime',
|
||||
buffer: Buffer.from('playwright mov upload smoke'),
|
||||
},
|
||||
},
|
||||
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'),
|
||||
});
|
||||
expect(upload.status(), await upload.text()).toBe(200);
|
||||
const completeResponse = await completeResponsePromise;
|
||||
expect(completeResponse.status(), await completeResponse.text()).toBe(200);
|
||||
|
||||
await expect(page.getByText(/上传失败|素材列表加载失败|素材库加载失败/)).toHaveCount(0, { timeout: 5_000 });
|
||||
|
||||
@@ -138,12 +108,7 @@ test.describe('Core media upload flow', () => {
|
||||
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByText(/素材就绪度|Ready/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/预计成片|视频素材数量偏少|素材准备度良好/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText('e2e-sample.MOV', { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
await page.locator('.xx-vertical-card').filter({ hasText: 'e2e-sample.MOV' }).getByRole('button', { name: /通\s*过/ }).click();
|
||||
await expect(page.getByText('复核状态已更新')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(/已通过|approved/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/素材加载失败|上传失败/)).toHaveCount(0);
|
||||
await expect(page.getByRole('cell', { name: 'e2e-sample.MOV', exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/素材列表加载失败|上传失败/)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// This file is auto-used by Next.js TypeScript projects.
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
Generated
+11
-11
@@ -1246,13 +1246,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.45.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.0.tgz",
|
||||
"integrity": "sha512-TVYsfMlGAaxeUllNkywbwek67Ncf8FRGn8ZlRdO291OL3NjG9oMbfVhyP82HQF0CZLMrYsvesqoUekxdWuF9Qw==",
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
|
||||
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.45.0"
|
||||
"playwright": "1.61.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -5037,13 +5037,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.45.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.0.tgz",
|
||||
"integrity": "sha512-4z3ac3plDfYzGB6r0Q3LF8POPR20Z8D0aXcxbJvmfMgSSq1hkcgvFRXJk9rUq5H/MJ0Ktal869hhOdI/zUTeLA==",
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
||||
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.45.0"
|
||||
"playwright-core": "1.61.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -5056,9 +5056,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.45.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.0.tgz",
|
||||
"integrity": "sha512-lZmHlFQ0VYSpAs43dRq1/nJ9G/6SiTI7VPqidld9TDefL9tX87bTKExWZZUF5PeRyqtXqd8fQi2qmfIedkwsNQ==",
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.App {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState } from 'react'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<h1>小虾 SaaS</h1>
|
||||
<p>自动化视频剪辑平台</p>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
</div>
|
||||
<p className="read-the-docs">
|
||||
Phase 6: 前端开发中...
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
+31
-99
@@ -1,13 +1,12 @@
|
||||
/**
|
||||
* 素材相关 API
|
||||
* Phase 1 重构:去掉 project_id,素材直接归属用户
|
||||
*/
|
||||
import apiClient from './client';
|
||||
import { getOrCreateDefaultProject } from './projects';
|
||||
|
||||
/** 素材条目 */
|
||||
export interface AssetItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
name: string;
|
||||
storage_key: string;
|
||||
@@ -17,22 +16,22 @@ export interface AssetItem {
|
||||
status?: string;
|
||||
classification_status?: string | null;
|
||||
quality_score?: number | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/** 素材库 */
|
||||
export interface AssetLibraryItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
kind: 'video' | 'voice' | 'image';
|
||||
asset_count?: number;
|
||||
total_size?: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/** 入库任务 */
|
||||
export interface IngestJob {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
storage_key: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
@@ -40,9 +39,10 @@ export interface IngestJob {
|
||||
result_asset_id: string;
|
||||
}
|
||||
|
||||
/** 分类任务 */
|
||||
export interface ClassificationJob {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
asset_id: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
classification: string;
|
||||
@@ -50,98 +50,30 @@ export interface ClassificationJob {
|
||||
error_message: string;
|
||||
}
|
||||
|
||||
/** 素材诊断信息 */
|
||||
export interface AssetDiagnosis {
|
||||
readiness_score: number;
|
||||
readiness_label: string;
|
||||
total_assets: number;
|
||||
ready_assets: number;
|
||||
video_assets: number;
|
||||
image_assets: number;
|
||||
voice_assets: number;
|
||||
total_duration_seconds: number;
|
||||
estimated_video_count: number;
|
||||
used_assets: number;
|
||||
unused_assets: number;
|
||||
pending_review_assets: number;
|
||||
smart_views: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
description: string;
|
||||
}>;
|
||||
gaps: Array<{
|
||||
key: string;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
message: string;
|
||||
recommendation: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ─── 素材诊断 ──────────────────────────────────────────────
|
||||
|
||||
/** 获取当前用户的素材诊断信息 */
|
||||
export const getAssetDiagnosis = async (): Promise<AssetDiagnosis> => {
|
||||
const response = await apiClient.get('/asset-diagnosis');
|
||||
return response.data;
|
||||
export const getAssetLibraries = async (projectId: string): Promise<AssetLibraryItem[]> => {
|
||||
const response = await apiClient.get('/asset-libraries', {
|
||||
params: { project_id: projectId },
|
||||
});
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
// ─── 素材库 ────────────────────────────────────────────────
|
||||
|
||||
/** 获取当前用户的所有素材库 */
|
||||
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
|
||||
const response = await apiClient.get('/asset-libraries');
|
||||
return response.data.items || [];
|
||||
};
|
||||
|
||||
/** 创建素材库(自动获取或创建默认项目以提供 project_id) */
|
||||
export const createAssetLibrary = async (data: {
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
kind: 'video' | 'voice' | 'image';
|
||||
}): Promise<AssetLibraryItem> => {
|
||||
// 后端要求 project_id,前端自动管理默认项目
|
||||
const project = await getOrCreateDefaultProject();
|
||||
const response = await apiClient.post('/asset-libraries', {
|
||||
project_id: project.id,
|
||||
...data,
|
||||
});
|
||||
const response = await apiClient.post('/asset-libraries', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除素材库 */
|
||||
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
|
||||
await apiClient.delete(`/asset-libraries/${libraryId}`);
|
||||
};
|
||||
|
||||
// ─── 素材 ──────────────────────────────────────────────────
|
||||
|
||||
/** 获取素材库下的所有素材 */
|
||||
export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
|
||||
const response = await apiClient.get('/assets', {
|
||||
params: { library_id: libraryId },
|
||||
});
|
||||
return response.data.items || [];
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
/** 更新素材审核状态 */
|
||||
export const updateAssetReviewStatus = async (
|
||||
assetId: string,
|
||||
reviewStatus: 'pending_review' | 'approved' | 'rejected'
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.patch(`/assets/${assetId}/review`, {
|
||||
review_status: reviewStatus,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除素材 */
|
||||
export const deleteAsset = async (assetId: string): Promise<void> => {
|
||||
await apiClient.delete(`/assets/${assetId}`);
|
||||
};
|
||||
|
||||
// ─── 上传 ──────────────────────────────────────────────────
|
||||
|
||||
/** 表单上传素材(小文件) */
|
||||
export const uploadAsset = async (
|
||||
formData: FormData
|
||||
): Promise<{ storage_key: string; ingest_job_id: string; url: string }> => {
|
||||
@@ -152,8 +84,9 @@ export const uploadAsset = async (
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 预签名直传准备 */
|
||||
export const prepareDirectUpload = async (data: {
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
@@ -170,8 +103,9 @@ export const prepareDirectUpload = async (data: {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 直传完成确认 */
|
||||
export const completeDirectUpload = async (data: {
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
storage_key: string;
|
||||
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
|
||||
@@ -179,12 +113,15 @@ export const completeDirectUpload = async (data: {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 直传上传(大文件推荐) */
|
||||
export const uploadAssetDirect = async (data: {
|
||||
file: File;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
|
||||
const prepared = await prepareDirectUpload({
|
||||
workspace_id: data.workspace_id,
|
||||
project_id: data.project_id,
|
||||
library_id: data.library_id,
|
||||
filename: data.file.name,
|
||||
content_type: data.file.type || 'application/octet-stream',
|
||||
@@ -192,9 +129,7 @@ export const uploadAssetDirect = async (data: {
|
||||
});
|
||||
|
||||
const directForm = new FormData();
|
||||
Object.entries(prepared.fields).forEach(([key, value]) =>
|
||||
directForm.append(key, value)
|
||||
);
|
||||
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value));
|
||||
directForm.append('file', data.file);
|
||||
|
||||
const uploadResponse = await fetch(prepared.upload_url, {
|
||||
@@ -206,31 +141,28 @@ export const uploadAssetDirect = async (data: {
|
||||
}
|
||||
|
||||
return completeDirectUpload({
|
||||
workspace_id: data.workspace_id,
|
||||
project_id: data.project_id,
|
||||
library_id: data.library_id,
|
||||
storage_key: prepared.storage_key,
|
||||
});
|
||||
};
|
||||
|
||||
// ─── 入库 / 分类任务 ───────────────────────────────────────
|
||||
|
||||
/** 查询入库任务状态 */
|
||||
export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
|
||||
const response = await apiClient.get(`/ingest-jobs/${jobId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 提交素材分类任务 */
|
||||
export const submitClassificationJob = async (data: {
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
asset_id: string;
|
||||
}): Promise<ClassificationJob> => {
|
||||
const response = await apiClient.post('/classification-jobs', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 查询分类任务状态 */
|
||||
export const getClassificationJob = async (
|
||||
jobId: string
|
||||
): Promise<ClassificationJob> => {
|
||||
export const getClassificationJob = async (jobId: string): Promise<ClassificationJob> => {
|
||||
const response = await apiClient.get(`/classification-jobs/${jobId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* 封装 Axios 实例,配置拦截器和 Token 管理
|
||||
*/
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { message } from 'antd';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
// 创建 Axios 实例
|
||||
@@ -29,43 +28,14 @@ apiClient.interceptors.request.use(
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器:统一错误提示 + 处理未授权状态
|
||||
// 响应拦截器:处理未授权状态
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => {
|
||||
// 401 → 清除登录态
|
||||
async (error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
useAuthStore.getState().clearAuth();
|
||||
}
|
||||
|
||||
// 提取后端返回的错误信息(detail / message / msg)
|
||||
const data = error.response?.data;
|
||||
const serverMsg = data?.detail || data?.message || data?.msg;
|
||||
let handled = false;
|
||||
|
||||
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
||||
message.error('请求超时,请检查网络后重试');
|
||||
handled = true;
|
||||
} else if (!error.response) {
|
||||
message.error('网络连接异常,请检查网络设置');
|
||||
handled = true;
|
||||
} else if (serverMsg) {
|
||||
message.error(serverMsg);
|
||||
handled = true;
|
||||
} else {
|
||||
const status = error.response?.status;
|
||||
if (status && status >= 500) {
|
||||
message.error('服务器繁忙,请稍后再试');
|
||||
handled = true;
|
||||
}
|
||||
// 4xx 且无具体信息时不弹通用提示,由各组件自行处理
|
||||
}
|
||||
|
||||
// 标记已展示过提示,组件 onError 可据此跳过重复 toast
|
||||
if (handled) {
|
||||
(error as any).__msgShown = true;
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* 仪表盘 API
|
||||
* Phase 1 新增:用户仪表盘概览
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
/** 仪表盘概览数据 */
|
||||
export interface DashboardOverview {
|
||||
/** 素材总数 */
|
||||
total_assets: number;
|
||||
/** 已用存储(字节) */
|
||||
used_storage_bytes: number;
|
||||
/** 总标题数 */
|
||||
total_titles: number;
|
||||
/** 总配音数 */
|
||||
total_voices: number;
|
||||
/** 生成任务总数 */
|
||||
total_tasks: number;
|
||||
/** 成品总数 */
|
||||
total_products: number;
|
||||
/** 最近生成任务 */
|
||||
recent_tasks: Array<{
|
||||
id: string;
|
||||
task_type: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
user_message: string;
|
||||
created_at: string;
|
||||
}>;
|
||||
/** 订阅信息 */
|
||||
subscription: {
|
||||
plan: 'free' | 'pro' | 'enterprise';
|
||||
status: 'active' | 'inactive' | 'expired';
|
||||
expires_at?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取仪表盘概览数据 */
|
||||
export const getDashboardOverview =
|
||||
async (): Promise<DashboardOverview> => {
|
||||
const response = await apiClient.get('/dashboard/overview');
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,234 +0,0 @@
|
||||
/**
|
||||
* 查重 API 模块
|
||||
* 提供视频查重相关接口(当前使用 mock 数据,后端就绪后切换)
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
/** 查重记录状态 */
|
||||
export type DuplicationStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
||||
|
||||
/** 查重记录 */
|
||||
export interface DuplicationRecord {
|
||||
id: string;
|
||||
/** 原始文件名 */
|
||||
filename: string;
|
||||
/** 文件大小(字节) */
|
||||
file_size: number;
|
||||
/** 时长(秒) */
|
||||
duration_seconds?: number;
|
||||
/** 状态 */
|
||||
status: DuplicationStatus;
|
||||
/** 查重率(0-100) */
|
||||
duplicate_rate?: number;
|
||||
/** 重复片段数 */
|
||||
duplicate_count?: number;
|
||||
/** 创建时间 */
|
||||
created_at: string;
|
||||
/** 更新时间 */
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 重复片段详情 */
|
||||
export interface DuplicateSegment {
|
||||
id: string;
|
||||
/** 在原始视频中的起始时间(秒) */
|
||||
source_start: number;
|
||||
/** 在原始视频中的结束时间(秒) */
|
||||
source_end: number;
|
||||
/** 匹配到的已有视频 ID */
|
||||
matched_video_id: string;
|
||||
/** 匹配到的已有视频名称 */
|
||||
matched_video_name: string;
|
||||
/** 匹配片段在已有视频中的起始时间 */
|
||||
matched_start: number;
|
||||
/** 匹配片段在已有视频中的结束时间 */
|
||||
matched_end: number;
|
||||
/** 相似度(0-100) */
|
||||
similarity: number;
|
||||
}
|
||||
|
||||
/** 查重详情 */
|
||||
export interface DuplicationDetail extends DuplicationRecord {
|
||||
/** 重复片段列表 */
|
||||
segments: DuplicateSegment[];
|
||||
}
|
||||
|
||||
/** 上传查重响应 */
|
||||
export interface DuplicationUploadResponse {
|
||||
/** 查重记录 ID */
|
||||
id: string;
|
||||
/** 状态 */
|
||||
status: DuplicationStatus;
|
||||
/** 消息 */
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============ Mock 数据 ============
|
||||
|
||||
/** mock 查重记录列表 */
|
||||
const MOCK_RECORDS: DuplicationRecord[] = [
|
||||
{
|
||||
id: 'dup-001',
|
||||
filename: '日常vlog_01.mp4',
|
||||
file_size: 125_000_000,
|
||||
duration_seconds: 180,
|
||||
status: 'completed',
|
||||
duplicate_rate: 23.5,
|
||||
duplicate_count: 3,
|
||||
created_at: '2026-06-27T10:00:00Z',
|
||||
updated_at: '2026-06-27T10:05:00Z',
|
||||
},
|
||||
{
|
||||
id: 'dup-002',
|
||||
filename: '美食分享_片段.mp4',
|
||||
file_size: 45_000_000,
|
||||
duration_seconds: 60,
|
||||
status: 'completed',
|
||||
duplicate_rate: 5.2,
|
||||
duplicate_count: 1,
|
||||
created_at: '2026-06-27T11:30:00Z',
|
||||
updated_at: '2026-06-27T11:32:00Z',
|
||||
},
|
||||
{
|
||||
id: 'dup-003',
|
||||
filename: '旅行记录_巴黎.mp4',
|
||||
file_size: 320_000_000,
|
||||
duration_seconds: 420,
|
||||
status: 'processing',
|
||||
created_at: '2026-06-28T09:00:00Z',
|
||||
updated_at: '2026-06-28T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'dup-004',
|
||||
filename: '产品展示_新版.mp4',
|
||||
file_size: 88_000_000,
|
||||
duration_seconds: 90,
|
||||
status: 'completed',
|
||||
duplicate_rate: 67.8,
|
||||
duplicate_count: 8,
|
||||
created_at: '2026-06-26T15:00:00Z',
|
||||
updated_at: '2026-06-26T15:10:00Z',
|
||||
},
|
||||
{
|
||||
id: 'dup-005',
|
||||
filename: '教程_剪辑技巧.mp4',
|
||||
file_size: 200_000_000,
|
||||
duration_seconds: 300,
|
||||
status: 'failed',
|
||||
created_at: '2026-06-26T14:00:00Z',
|
||||
updated_at: '2026-06-26T14:01:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
/** mock 查重详情 */
|
||||
const MOCK_DETAIL: DuplicationDetail = {
|
||||
...MOCK_RECORDS[0],
|
||||
segments: [
|
||||
{
|
||||
id: 'seg-001',
|
||||
source_start: 10,
|
||||
source_end: 25,
|
||||
matched_video_id: 'asset-101',
|
||||
matched_video_name: '日常vlog_素材库.mp4',
|
||||
matched_start: 45,
|
||||
matched_end: 60,
|
||||
similarity: 92.3,
|
||||
},
|
||||
{
|
||||
id: 'seg-002',
|
||||
source_start: 60,
|
||||
source_end: 78,
|
||||
matched_video_id: 'asset-205',
|
||||
matched_video_name: '城市风光_合集.mp4',
|
||||
matched_start: 120,
|
||||
matched_end: 138,
|
||||
similarity: 85.7,
|
||||
},
|
||||
{
|
||||
id: 'seg-003',
|
||||
source_start: 150,
|
||||
source_end: 165,
|
||||
matched_video_id: 'asset-310',
|
||||
matched_video_name: '背景音乐_配套画面.mp4',
|
||||
matched_start: 30,
|
||||
matched_end: 45,
|
||||
similarity: 78.1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** 是否使用 mock 数据(后端就绪后改为 false) */
|
||||
const USE_MOCK = true;
|
||||
|
||||
// ============ API 函数 ============
|
||||
|
||||
/** 上传视频进行查重 */
|
||||
export const uploadForDuplication = async (
|
||||
file: File
|
||||
): Promise<DuplicationUploadResponse> => {
|
||||
if (USE_MOCK) {
|
||||
// 模拟上传延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
return {
|
||||
id: `dup-${Date.now()}`,
|
||||
status: 'processing',
|
||||
message: `文件 "${file.name}" 已上传,正在查重中...`,
|
||||
};
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await apiClient.post('/duplication/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 获取查重记录列表 */
|
||||
export const getDuplicationRecords = async (): Promise<DuplicationRecord[]> => {
|
||||
if (USE_MOCK) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return MOCK_RECORDS;
|
||||
}
|
||||
const response = await apiClient.get('/duplication/records');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 获取查重详情 */
|
||||
export const getDuplicationDetail = async (
|
||||
recordId: string
|
||||
): Promise<DuplicationDetail> => {
|
||||
if (USE_MOCK) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
// 返回第一条 mock 详情,实际应按 ID 查找
|
||||
return { ...MOCK_DETAIL, id: recordId };
|
||||
}
|
||||
const response = await apiClient.get(`/duplication/records/${recordId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除查重记录 */
|
||||
export const deleteDuplicationRecord = async (
|
||||
recordId: string
|
||||
): Promise<void> => {
|
||||
if (USE_MOCK) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return;
|
||||
}
|
||||
await apiClient.delete(`/duplication/records/${recordId}`);
|
||||
};
|
||||
|
||||
/** 重新查重 */
|
||||
export const retryDuplication = async (
|
||||
recordId: string
|
||||
): Promise<DuplicationUploadResponse> => {
|
||||
if (USE_MOCK) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
return {
|
||||
id: recordId,
|
||||
status: 'processing',
|
||||
message: '已重新提交查重',
|
||||
};
|
||||
}
|
||||
const response = await apiClient.post(`/duplication/records/${recordId}/retry`);
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* 编辑计划 API
|
||||
* Phase 1 重构:去掉 projectId,编辑计划直接归属用户
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
/** 编辑模式 */
|
||||
export type EditingMode = 'one-take' | 'pip' | 'voiceover' | 'voice_pip';
|
||||
|
||||
/** 编辑模板 */
|
||||
export interface EditTemplateItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
target_duration: number;
|
||||
clip_count: number;
|
||||
is_active: boolean;
|
||||
category?: string;
|
||||
thumbnail_url?: string;
|
||||
}
|
||||
|
||||
/** 编辑计划片段 */
|
||||
export interface EditPlanClipItem {
|
||||
id: string;
|
||||
asset_id: string;
|
||||
asset_name: string;
|
||||
sequence: number;
|
||||
start_time: number;
|
||||
duration: number;
|
||||
reason: string;
|
||||
layer?: 'main' | 'pip' | 'broll';
|
||||
thumbnail_url?: string;
|
||||
}
|
||||
|
||||
/** 编辑计划 */
|
||||
export interface EditPlanItem {
|
||||
id: string;
|
||||
template_id: string;
|
||||
asset_library_id: string;
|
||||
title_id: string;
|
||||
status: string;
|
||||
editing_mode?: EditingMode;
|
||||
summary: string;
|
||||
clips: EditPlanClipItem[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// ─── 编辑计划 ──────────────────────────────────────────────
|
||||
|
||||
/** 获取当前用户的编辑计划列表 */
|
||||
export const getEditPlans = async (): Promise<EditPlanItem[]> => {
|
||||
const response = await apiClient.get('/edit-plans');
|
||||
return response.data.items || [];
|
||||
};
|
||||
|
||||
/** 获取单个编辑计划 */
|
||||
export const getEditPlan = async (planId: string): Promise<EditPlanItem> => {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 创建编辑计划 */
|
||||
export const createEditPlan = async (data: {
|
||||
asset_library_id: string;
|
||||
template_id?: string;
|
||||
title_id?: string;
|
||||
}): Promise<EditPlanItem> => {
|
||||
const response = await apiClient.post('/edit-plans', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 智能编排 - 自动生成编辑计划 */
|
||||
export const autoGenerateEditPlan = async (params: {
|
||||
template_id: string;
|
||||
asset_ids?: string[];
|
||||
title_ids?: string[];
|
||||
voice_ids?: string[];
|
||||
editing_mode?: EditingMode;
|
||||
target_duration?: number;
|
||||
}): Promise<EditPlanItem> => {
|
||||
const response = await apiClient.post('/edit-plans/auto-generate', params);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 更新编辑计划 */
|
||||
export const updateEditPlan = async (
|
||||
planId: string,
|
||||
data: Partial<EditPlanItem>
|
||||
): Promise<EditPlanItem> => {
|
||||
const response = await apiClient.patch(`/edit-plans/${planId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除编辑计划 */
|
||||
export const deleteEditPlan = async (planId: string): Promise<void> => {
|
||||
await apiClient.delete(`/edit-plans/${planId}`);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface GenerationTaskItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
asset_library_id: string;
|
||||
strategy_id?: string | null;
|
||||
voice_library_id?: string | null;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
progress: number;
|
||||
result_count: number;
|
||||
error_message?: string | null;
|
||||
}
|
||||
|
||||
export interface GeneratedVideoItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
generation_task_id: string;
|
||||
name: string;
|
||||
file_url: string;
|
||||
file_size: number;
|
||||
duration: number;
|
||||
thumbnail_url?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
}
|
||||
|
||||
export const createGenerationTask = async (data: {
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
asset_library_id: string;
|
||||
strategy_id?: string;
|
||||
voice_library_id?: string;
|
||||
created_by_user_id?: string;
|
||||
}): Promise<GenerationTaskItem> => {
|
||||
const response = await apiClient.post('/generation/tasks', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getGenerationTask = async (taskId: string): Promise<GenerationTaskItem> => {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getGenerationResults = async (taskId: string): Promise<GeneratedVideoItem[]> => {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`);
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const getGeneratedVideos = async (projectId: string): Promise<GeneratedVideoItem[]> => {
|
||||
const response = await apiClient.get('/generated-videos', { params: { project_id: projectId } });
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const getGeneratedVideoDownloadUrl = async (videoId: string): Promise<string> => {
|
||||
const response = await apiClient.get(`/generated-videos/${videoId}/download-url`);
|
||||
return response.data.download_url;
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* 成品相关 API
|
||||
* Phase 1 重构:去掉 projectId,成品直接归属用户
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
/** 成品条目 */
|
||||
export interface ProductItem {
|
||||
id: string;
|
||||
title: string;
|
||||
video_url?: string;
|
||||
thumbnail_url?: string;
|
||||
duration_seconds?: number;
|
||||
file_size?: number;
|
||||
resolution?: string;
|
||||
status: 'processing' | 'completed' | 'failed';
|
||||
/** 查重率(百分比) */
|
||||
duplicate_rate?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有成品 */
|
||||
export const getProducts = async (): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get('/products');
|
||||
return response.data.items || response.data || [];
|
||||
};
|
||||
|
||||
/** 获取单个成品详情 */
|
||||
export const getProduct = async (
|
||||
productId: string
|
||||
): Promise<ProductItem> => {
|
||||
const response = await apiClient.get(`/products/${productId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除成品 */
|
||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||
await apiClient.delete(`/products/${productId}`);
|
||||
};
|
||||
|
||||
/** 获取成品下载链接 */
|
||||
export const getProductDownloadUrl = async (
|
||||
productId: string
|
||||
): Promise<{ url: string; expires_at: string }> => {
|
||||
const response = await apiClient.get(
|
||||
`/products/${productId}/download-url`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,60 +1,29 @@
|
||||
/**
|
||||
* 项目相关 API
|
||||
* 素材库需要 project_id,前端自动管理默认项目
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
export interface ProjectItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 后端 ProjectResponse 只返回 id, name, description */
|
||||
interface BackendProjectResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 后端 ListProjectsResponse 返回 { items: [...] } */
|
||||
interface BackendListProjectsResponse {
|
||||
items: BackendProjectResponse[];
|
||||
}
|
||||
|
||||
const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
});
|
||||
|
||||
/** 获取当前用户的项目列表 */
|
||||
export const getProjects = async (): Promise<ProjectItem[]> => {
|
||||
const response = await apiClient.get<BackendListProjectsResponse>('/projects');
|
||||
return (response.data.items || []).map(toProjectItem);
|
||||
};
|
||||
|
||||
/** 创建项目 */
|
||||
export const createProject = async (data: {
|
||||
export interface CreateProjectRequest {
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}): Promise<ProjectItem> => {
|
||||
const response = await apiClient.post<BackendProjectResponse>('/projects', {
|
||||
name: data.name,
|
||||
description: data.description || '',
|
||||
});
|
||||
return toProjectItem(response.data);
|
||||
}
|
||||
|
||||
export const getProject = async (projectId: string): Promise<ProjectItem> => {
|
||||
const response = await apiClient.get(`/projects/${projectId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 获取或创建默认项目(素材库需要 project_id) */
|
||||
export const getOrCreateDefaultProject = async (): Promise<ProjectItem> => {
|
||||
const projects = await getProjects();
|
||||
if (projects.length > 0) {
|
||||
return projects[0];
|
||||
}
|
||||
// 没有项目时自动创建默认项目
|
||||
return createProject({
|
||||
name: '默认项目',
|
||||
description: '系统自动创建的默认项目',
|
||||
});
|
||||
export const getProjects = async (workspaceId: string): Promise<ProjectItem[]> => {
|
||||
const response = await apiClient.get('/projects', { params: { workspace_id: workspaceId } });
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const createProject = async (data: CreateProjectRequest): Promise<ProjectItem> => {
|
||||
const response = await apiClient.post('/projects', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -1,99 +1,67 @@
|
||||
/**
|
||||
* 订阅 API 模块
|
||||
* 对接后端订阅管理接口
|
||||
* 订阅相关 API
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
/** 套餐类型 */
|
||||
export type PlanType = 'free' | 'standard' | 'pro' | 'enterprise';
|
||||
|
||||
/** 订阅状态 */
|
||||
export type SubscriptionStatus = 'active' | 'expired' | 'cancelled' | 'trial';
|
||||
|
||||
/** 账单状态 */
|
||||
export type BillingStatus = 'paid' | 'pending' | 'failed' | 'refunded';
|
||||
|
||||
/** 计费周期 */
|
||||
export type BillingCycle = 'monthly' | 'yearly';
|
||||
|
||||
/** 套餐信息 */
|
||||
export interface Plan {
|
||||
id: PlanType;
|
||||
name: string;
|
||||
price: number | null;
|
||||
yearly_price?: number | null;
|
||||
description: string;
|
||||
recommended: boolean;
|
||||
// 类型定义
|
||||
export interface SubscriptionPlan {
|
||||
name: 'free' | 'pro' | 'enterprise';
|
||||
display_name: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
max_projects: number;
|
||||
max_storage_gb: number;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
/** 当前订阅信息 */
|
||||
export interface SubscriptionInfo {
|
||||
id: string;
|
||||
plan_id: PlanType;
|
||||
plan_name: string;
|
||||
status: SubscriptionStatus;
|
||||
billing_cycle: BillingCycle;
|
||||
current_period_start: string;
|
||||
current_period_end: string;
|
||||
amount: number;
|
||||
auto_renew: boolean;
|
||||
created_at: string;
|
||||
export interface QuotaStatus {
|
||||
projects: {
|
||||
used: number;
|
||||
limit: number;
|
||||
status: 'normal' | 'warning' | 'critical' | 'exceeded';
|
||||
};
|
||||
storage: {
|
||||
used_gb: number;
|
||||
limit_gb: number;
|
||||
status: 'normal' | 'warning' | 'critical' | 'exceeded';
|
||||
};
|
||||
}
|
||||
|
||||
/** 账单记录 */
|
||||
export interface BillingRecord {
|
||||
id: string;
|
||||
plan_name: string;
|
||||
amount: number;
|
||||
billing_cycle: BillingCycle;
|
||||
status: BillingStatus;
|
||||
payment_method: string;
|
||||
created_at: string;
|
||||
invoice_url?: string;
|
||||
}
|
||||
|
||||
/** 升级/降级请求 */
|
||||
export interface ChangePlanRequest {
|
||||
target_plan_id: PlanType;
|
||||
billing_cycle: BillingCycle;
|
||||
}
|
||||
|
||||
/** 升级/降级响应 */
|
||||
export interface ChangePlanResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
new_subscription?: SubscriptionInfo;
|
||||
}
|
||||
|
||||
// ============ API 函数 ============
|
||||
|
||||
/** 获取当前订阅信息 */
|
||||
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
|
||||
const response = await apiClient.get('/subscription/current');
|
||||
// 获取所有订阅计划
|
||||
export const getPlans = async (): Promise<SubscriptionPlan[]> => {
|
||||
const response = await apiClient.get('/subscriptions/plans');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 获取账单记录列表 */
|
||||
export const getBillingRecords = async (): Promise<BillingRecord[]> => {
|
||||
const response = await apiClient.get('/subscription/billing-records');
|
||||
// 获取当前订阅
|
||||
export const getCurrentSubscription = async (
|
||||
workspaceId: string
|
||||
): Promise<SubscriptionPlan> => {
|
||||
const response = await apiClient.get(`/workspaces/${workspaceId}/subscription`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 升级/降级套餐 */
|
||||
export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => {
|
||||
const response = await apiClient.post('/subscription/change-plan', request);
|
||||
// 升级订阅
|
||||
export const upgradeSubscription = async (
|
||||
workspaceId: string,
|
||||
plan: 'pro' | 'enterprise'
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post(`/workspaces/${workspaceId}/subscription/upgrade`, {
|
||||
plan,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 取消订阅 */
|
||||
export const cancelSubscription = async (): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.post('/subscription/cancel');
|
||||
// 取消订阅
|
||||
export const cancelSubscription = async (
|
||||
workspaceId: string
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post(`/workspaces/${workspaceId}/subscription/cancel`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 切换自动续费 */
|
||||
export const toggleAutoRenew = async (enabled: boolean): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.post('/subscription/toggle-auto-renew', { enabled });
|
||||
// 获取配额状态
|
||||
export const getQuotaStatus = async (workspaceId: string): Promise<QuotaStatus> => {
|
||||
const response = await apiClient.get(`/workspaces/${workspaceId}/quota`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* 任务相关 API
|
||||
* Phase 1 重构:去掉 projectId,任务直接归属用户
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
/** 任务条目 */
|
||||
export interface TaskItem {
|
||||
id: string;
|
||||
task_type: 'ingest' | 'generation' | string;
|
||||
status: string;
|
||||
progress: number;
|
||||
current_step: string;
|
||||
error_message: string;
|
||||
user_message: string;
|
||||
retryable: boolean;
|
||||
source_id: string;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有任务(生成记录) */
|
||||
export const getUserTasks = async (): Promise<TaskItem[]> => {
|
||||
const response = await apiClient.get('/tasks');
|
||||
return response.data.items || [];
|
||||
};
|
||||
|
||||
/** 重试失败的任务 */
|
||||
export const retryTask = async (taskId: string): Promise<TaskItem> => {
|
||||
const response = await apiClient.post(`/tasks/${taskId}/retry`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 创建生成任务请求参数 */
|
||||
export interface CreateGenerationTaskRequest {
|
||||
template_id: string;
|
||||
asset_ids: string[];
|
||||
title_ids: string[];
|
||||
voice_ids: string[];
|
||||
}
|
||||
|
||||
/** 创建生成任务响应 */
|
||||
export interface CreateGenerationTaskResponse {
|
||||
task_id: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// TODO: 后端生成接口适配扁平化架构后切换为 false
|
||||
const USE_MOCK = true;
|
||||
|
||||
/** 创建生成任务(一键生成) */
|
||||
export const createGenerationTask = async (
|
||||
params: CreateGenerationTaskRequest,
|
||||
): Promise<CreateGenerationTaskResponse> => {
|
||||
if (USE_MOCK) {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
return {
|
||||
task_id: `task_${Date.now()}`,
|
||||
status: 'pending',
|
||||
message: '生成任务已创建',
|
||||
};
|
||||
}
|
||||
const response = await apiClient.post<CreateGenerationTaskResponse>(
|
||||
'/generation/tasks',
|
||||
params,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* 模板相关 API
|
||||
* Phase 1 新增:全局模板库
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
/** 模板条目 */
|
||||
export interface TemplateItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
target_duration: number;
|
||||
clip_count: number;
|
||||
thumbnail_url?: string;
|
||||
preview_url?: string;
|
||||
is_active: boolean;
|
||||
is_favorite?: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/** 获取全局模板列表 */
|
||||
export const getTemplates = async (): Promise<TemplateItem[]> => {
|
||||
const response = await apiClient.get('/templates');
|
||||
return response.data.items || response.data || [];
|
||||
};
|
||||
|
||||
/** 获取单个模板详情 */
|
||||
export const getTemplate = async (
|
||||
templateId: string
|
||||
): Promise<TemplateItem> => {
|
||||
const response = await apiClient.get(`/templates/${templateId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 收藏 / 取消收藏模板 */
|
||||
export const toggleFavoriteTemplate = async (
|
||||
templateId: string
|
||||
): Promise<{ is_favorite: boolean }> => {
|
||||
const response = await apiClient.post(
|
||||
`/templates/${templateId}/toggle-favorite`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* 标题相关 API
|
||||
* Phase 1 新增:全局标题库
|
||||
* 注意:后端 schema 使用 name + text 字段,前端 UI 用 content 展示
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
/** 标题条目(前端展示用) */
|
||||
export interface TitleItem {
|
||||
id: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
source?: string;
|
||||
word_count?: number;
|
||||
is_favorite?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 后端标题响应格式 */
|
||||
interface BackendTitleResponse {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
text: string;
|
||||
category: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
usage_count: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 后端创建标题请求格式 */
|
||||
interface BackendCreateTitleRequest {
|
||||
name: string;
|
||||
text: string;
|
||||
category: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/** 后端更新标题请求格式 */
|
||||
interface BackendUpdateTitleRequest {
|
||||
name?: string;
|
||||
text?: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/** 将后端响应映射为前端 TitleItem */
|
||||
const toTitleItem = (item: BackendTitleResponse): TitleItem => ({
|
||||
id: item.id,
|
||||
content: item.text,
|
||||
category: item.category,
|
||||
word_count: item.text?.length || 0,
|
||||
created_at: item.created_at,
|
||||
updated_at: item.updated_at,
|
||||
});
|
||||
|
||||
/** 创建标题请求(前端接口,保持向后兼容) */
|
||||
export interface CreateTitleRequest {
|
||||
content: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有标题 */
|
||||
export const getTitles = async (): Promise<TitleItem[]> => {
|
||||
const response = await apiClient.get<{ items: BackendTitleResponse[] }>('/titles');
|
||||
return (response.data.items || []).map(toTitleItem);
|
||||
};
|
||||
|
||||
/** 创建标题 */
|
||||
export const createTitle = async (
|
||||
data: CreateTitleRequest,
|
||||
): Promise<TitleItem> => {
|
||||
// 后端要求 name(≤255)和 text(≤500),name 从 content 截取
|
||||
const payload: BackendCreateTitleRequest = {
|
||||
name: data.content.slice(0, 255),
|
||||
text: data.content.slice(0, 500),
|
||||
category: data.category || 'default',
|
||||
};
|
||||
const response = await apiClient.post<BackendTitleResponse>('/titles', payload);
|
||||
return toTitleItem(response.data);
|
||||
};
|
||||
|
||||
/** 更新标题 */
|
||||
export const updateTitle = async (
|
||||
titleId: string,
|
||||
data: Partial<CreateTitleRequest>,
|
||||
): Promise<TitleItem> => {
|
||||
const payload: BackendUpdateTitleRequest = {};
|
||||
if (data.content !== undefined) {
|
||||
payload.name = data.content.slice(0, 255);
|
||||
payload.text = data.content.slice(0, 500);
|
||||
}
|
||||
if (data.category !== undefined) {
|
||||
payload.category = data.category;
|
||||
}
|
||||
// 后端用 PUT,非 PATCH
|
||||
const response = await apiClient.put<BackendTitleResponse>(
|
||||
`/titles/${titleId}`,
|
||||
payload,
|
||||
);
|
||||
return toTitleItem(response.data);
|
||||
};
|
||||
|
||||
/** 删除标题 */
|
||||
export const deleteTitle = async (titleId: string): Promise<void> => {
|
||||
await apiClient.delete(`/titles/${titleId}`);
|
||||
};
|
||||
|
||||
/** 批量导入标题 */
|
||||
export const batchImportTitles = async (
|
||||
titles: string[],
|
||||
): Promise<{ imported_count: number }> => {
|
||||
const response = await apiClient.post('/titles/batch-import', { titles });
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* 配音相关 API
|
||||
* Phase 1 新增:全局配音库
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
/** 配音条目 */
|
||||
export interface VoiceItem {
|
||||
id: string;
|
||||
name: string;
|
||||
text: string;
|
||||
voice_type?: string;
|
||||
duration_seconds?: number;
|
||||
storage_key?: string;
|
||||
audio_url?: string;
|
||||
status?: string;
|
||||
is_favorite?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 创建配音请求 */
|
||||
export interface CreateVoiceRequest {
|
||||
name: string;
|
||||
text: string;
|
||||
voice_type?: string;
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有配音 */
|
||||
export const getVoices = async (): Promise<VoiceItem[]> => {
|
||||
const response = await apiClient.get('/voices');
|
||||
return response.data.items || response.data || [];
|
||||
};
|
||||
|
||||
/** 创建配音 */
|
||||
export const createVoice = async (
|
||||
data: CreateVoiceRequest
|
||||
): Promise<VoiceItem> => {
|
||||
const response = await apiClient.post('/voices', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 更新配音 */
|
||||
export const updateVoice = async (
|
||||
voiceId: string,
|
||||
data: Partial<CreateVoiceRequest>
|
||||
): Promise<VoiceItem> => {
|
||||
const response = await apiClient.patch(`/voices/${voiceId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除配音 */
|
||||
export const deleteVoice = async (voiceId: string): Promise<void> => {
|
||||
await apiClient.delete(`/voices/${voiceId}`);
|
||||
};
|
||||
|
||||
/** AI 生成配音 */
|
||||
export const generateAIVoice = async (data: {
|
||||
text: string;
|
||||
voice_type?: string;
|
||||
speed?: number;
|
||||
}): Promise<VoiceItem> => {
|
||||
const response = await apiClient.post('/voices/generate', data);
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 工作空间相关 API
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
owner_user_id?: string;
|
||||
subscription_plan: 'free' | 'pro' | 'enterprise';
|
||||
subscription_status: 'active' | 'inactive' | 'expired';
|
||||
created_at?: string;
|
||||
max_projects?: number;
|
||||
max_storage_gb?: number;
|
||||
used_storage_gb?: number;
|
||||
member_count?: number;
|
||||
user_role?: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkspaceRequest {
|
||||
name: string;
|
||||
subscription_plan?: 'free' | 'pro' | 'enterprise';
|
||||
}
|
||||
|
||||
export interface WorkspaceMember {
|
||||
id: string;
|
||||
member_id: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name?: string;
|
||||
role: 'owner' | 'admin' | 'member' | 'viewer';
|
||||
joined_at: string;
|
||||
}
|
||||
|
||||
export interface InviteMemberRequest {
|
||||
email: string;
|
||||
role: 'admin' | 'member' | 'viewer';
|
||||
}
|
||||
|
||||
export interface InviteMemberResponse {
|
||||
invitation_id: string;
|
||||
invitee_email: string;
|
||||
role: 'admin' | 'member' | 'viewer';
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
type WorkspaceApiResponse = Omit<Workspace, 'id' | 'workspace_id'> & {
|
||||
id?: string;
|
||||
workspace_id?: string;
|
||||
};
|
||||
|
||||
type WorkspaceMemberApiResponse = Omit<WorkspaceMember, 'id' | 'member_id'> & {
|
||||
id?: string;
|
||||
member_id?: string;
|
||||
};
|
||||
|
||||
const normalizeWorkspace = (workspace: WorkspaceApiResponse): Workspace => {
|
||||
const workspaceId = workspace.id || workspace.workspace_id || '';
|
||||
return {
|
||||
...workspace,
|
||||
id: workspaceId,
|
||||
workspace_id: workspaceId,
|
||||
subscription_status: workspace.subscription_status || 'active',
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeMember = (member: WorkspaceMemberApiResponse): WorkspaceMember => {
|
||||
const memberId = member.member_id || member.id || '';
|
||||
return {
|
||||
...member,
|
||||
id: member.user_id || memberId,
|
||||
member_id: memberId,
|
||||
};
|
||||
};
|
||||
|
||||
export const getWorkspaces = async (): Promise<Workspace[]> => {
|
||||
const response = await apiClient.get('/workspaces');
|
||||
const payload = response.data;
|
||||
const workspaces = Array.isArray(payload) ? payload : payload.workspaces || [];
|
||||
return workspaces.map(normalizeWorkspace);
|
||||
};
|
||||
|
||||
export const getWorkspace = async (id: string): Promise<Workspace> => {
|
||||
const response = await apiClient.get(`/workspaces/${id}`);
|
||||
return normalizeWorkspace(response.data);
|
||||
};
|
||||
|
||||
export const createWorkspace = async (
|
||||
data: CreateWorkspaceRequest
|
||||
): Promise<Workspace> => {
|
||||
const response = await apiClient.post('/workspaces', data);
|
||||
return normalizeWorkspace(response.data);
|
||||
};
|
||||
|
||||
export const getMembers = async (workspaceId: string): Promise<WorkspaceMember[]> => {
|
||||
const response = await apiClient.get(`/workspaces/${workspaceId}/members`);
|
||||
const payload = response.data;
|
||||
const members = Array.isArray(payload) ? payload : payload.members || [];
|
||||
return members.map(normalizeMember);
|
||||
};
|
||||
|
||||
export const inviteMember = async (
|
||||
workspaceId: string,
|
||||
data: InviteMemberRequest
|
||||
): Promise<InviteMemberResponse> => {
|
||||
const response = await apiClient.post(`/workspaces/${workspaceId}/members/invite`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const removeMember = async (
|
||||
workspaceId: string,
|
||||
memberId: string
|
||||
): Promise<void> => {
|
||||
await apiClient.delete(`/workspaces/${workspaceId}/members/${memberId}`);
|
||||
};
|
||||
|
||||
export const updateMemberRole = async (
|
||||
workspaceId: string,
|
||||
memberId: string,
|
||||
role: 'admin' | 'member' | 'viewer'
|
||||
): Promise<{ user_id: string; old_role: string; new_role: string }> => {
|
||||
const response = await apiClient.patch(`/workspaces/${workspaceId}/members/${memberId}/role`, {
|
||||
role,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const leaveWorkspace = async (_workspaceId: string): Promise<{ message: string }> => {
|
||||
throw new Error('主动退出工作空间暂未开放');
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 邀请成员弹窗
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Modal, Form, Input, Select, message } from 'antd';
|
||||
import { useInviteMember } from '@/hooks/useWorkspace';
|
||||
|
||||
interface InviteMemberModalProps {
|
||||
workspaceId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const InviteMemberModal: React.FC<InviteMemberModalProps> = ({
|
||||
workspaceId,
|
||||
open,
|
||||
onClose,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const inviteMutation = useInviteMember(workspaceId);
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await inviteMutation.mutateAsync(values);
|
||||
message.success('邀请已发送!');
|
||||
form.resetFields();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
// 验证失败或请求失败
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="邀请成员"
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={inviteMutation.isPending}
|
||||
okText="发送邀请"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱地址"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱地址' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="member@example.com" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="role"
|
||||
label="角色"
|
||||
initialValue="member"
|
||||
rules={[{ required: true, message: '请选择角色' }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'admin', label: '管理员 - 可以管理成员和项目' },
|
||||
{ value: 'member', label: '成员 - 可以创建和管理项目' },
|
||||
{ value: 'viewer', label: '访客 - 只读权限' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteMemberModal;
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 成员列表组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Table, Button, Tag, Space, Popconfirm, message, Select } from 'antd';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
import { useWorkspaceMembers } from '@/hooks/useWorkspace';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { removeMember, updateMemberRole } from '@/api/workspace';
|
||||
|
||||
interface MemberListProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
const MemberList: React.FC<MemberListProps> = ({ workspaceId }) => {
|
||||
const { data: members, isLoading } = useWorkspaceMembers(workspaceId);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (memberId: string) => removeMember(workspaceId, memberId),
|
||||
onSuccess: () => {
|
||||
message.success('成员已移除');
|
||||
queryClient.invalidateQueries({ queryKey: ['workspaceMembers', workspaceId] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateRoleMutation = useMutation({
|
||||
mutationFn: ({ memberId, role }: { memberId: string; role: string }) =>
|
||||
updateMemberRole(workspaceId, memberId, role as any),
|
||||
onSuccess: () => {
|
||||
message.success('角色已更新');
|
||||
queryClient.invalidateQueries({ queryKey: ['workspaceMembers', workspaceId] });
|
||||
},
|
||||
});
|
||||
|
||||
const getRoleTag = (role: string) => {
|
||||
const roleConfig: Record<string, { color: string; text: string }> = {
|
||||
owner: { color: 'gold', text: '拥有者' },
|
||||
admin: { color: 'blue', text: '管理员' },
|
||||
member: { color: 'green', text: '成员' },
|
||||
viewer: { color: 'default', text: '访客' },
|
||||
};
|
||||
const config = roleConfig[role] || roleConfig.member;
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
render: (role: string, record: any) => {
|
||||
if (role === 'owner') {
|
||||
return getRoleTag(role);
|
||||
}
|
||||
return (
|
||||
<Select
|
||||
value={role}
|
||||
style={{ width: 120 }}
|
||||
onChange={(newRole) =>
|
||||
updateRoleMutation.mutate({ memberId: record.id, role: newRole })
|
||||
}
|
||||
options={[
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'member', label: '成员' },
|
||||
{ value: 'viewer', label: '访客' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '加入时间',
|
||||
dataIndex: 'joined_at',
|
||||
key: 'joined_at',
|
||||
render: (date: string) => new Date(date).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: any) => {
|
||||
if (record.role === 'owner') {
|
||||
return <span style={{ color: '#999' }}>-</span>;
|
||||
}
|
||||
return (
|
||||
<Space>
|
||||
<Popconfirm
|
||||
title="确定移除该成员?"
|
||||
onConfirm={() => removeMutation.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={removeMutation.isPending}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={members}
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberList;
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 权限矩阵展示组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Table } from 'antd';
|
||||
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
|
||||
const PermissionMatrix: React.FC = () => {
|
||||
const permissions = [
|
||||
{ action: '查看工作空间', owner: true, admin: true, member: true, viewer: true },
|
||||
{ action: '创建项目', owner: true, admin: true, member: true, viewer: false },
|
||||
{ action: '编辑项目', owner: true, admin: true, member: true, viewer: false },
|
||||
{ action: '删除项目', owner: true, admin: true, member: false, viewer: false },
|
||||
{ action: '邀请成员', owner: true, admin: true, member: false, viewer: false },
|
||||
{ action: '移除成员', owner: true, admin: true, member: false, viewer: false },
|
||||
{ action: '修改成员角色', owner: true, admin: true, member: false, viewer: false },
|
||||
{ action: '升级订阅', owner: true, admin: false, member: false, viewer: false },
|
||||
{ action: '删除工作空间', owner: true, admin: false, member: false, viewer: false },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
fixed: 'left' as const,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '拥有者',
|
||||
dataIndex: 'owner',
|
||||
key: 'owner',
|
||||
align: 'center' as const,
|
||||
render: (value: boolean) =>
|
||||
value ? (
|
||||
<CheckOutlined style={{ color: '#52c41a', fontSize: '16px' }} />
|
||||
) : (
|
||||
<CloseOutlined style={{ color: '#ff4d4f', fontSize: '16px' }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '管理员',
|
||||
dataIndex: 'admin',
|
||||
key: 'admin',
|
||||
align: 'center' as const,
|
||||
render: (value: boolean) =>
|
||||
value ? (
|
||||
<CheckOutlined style={{ color: '#52c41a', fontSize: '16px' }} />
|
||||
) : (
|
||||
<CloseOutlined style={{ color: '#ff4d4f', fontSize: '16px' }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '成员',
|
||||
dataIndex: 'member',
|
||||
key: 'member',
|
||||
align: 'center' as const,
|
||||
render: (value: boolean) =>
|
||||
value ? (
|
||||
<CheckOutlined style={{ color: '#52c41a', fontSize: '16px' }} />
|
||||
) : (
|
||||
<CloseOutlined style={{ color: '#ff4d4f', fontSize: '16px' }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '访客',
|
||||
dataIndex: 'viewer',
|
||||
key: 'viewer',
|
||||
align: 'center' as const,
|
||||
render: (value: boolean) =>
|
||||
value ? (
|
||||
<CheckOutlined style={{ color: '#52c41a', fontSize: '16px' }} />
|
||||
) : (
|
||||
<CloseOutlined style={{ color: '#ff4d4f', fontSize: '16px' }} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="权限说明">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={permissions}
|
||||
pagination={false}
|
||||
rowKey="action"
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionMatrix;
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 配额使用展示组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Progress, Row, Col, Tag, Space } from 'antd';
|
||||
import { ProjectOutlined, CloudOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
interface QuotaStatus {
|
||||
projects: {
|
||||
used: number;
|
||||
limit: number;
|
||||
status: string;
|
||||
};
|
||||
storage: {
|
||||
used_gb: number;
|
||||
limit_gb: number;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface QuotaDisplayProps {
|
||||
quota: QuotaStatus;
|
||||
}
|
||||
|
||||
const QuotaDisplay: React.FC<QuotaDisplayProps> = ({ quota }) => {
|
||||
const getStatusTag = (status: string) => {
|
||||
const config: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
|
||||
normal: { color: 'success', text: '正常', icon: null },
|
||||
warning: { color: 'warning', text: '接近上限', icon: <WarningOutlined /> },
|
||||
critical: { color: 'error', text: '即将超限', icon: <WarningOutlined /> },
|
||||
exceeded: { color: 'error', text: '已超限', icon: <WarningOutlined /> },
|
||||
};
|
||||
const item = config[status] || config.normal;
|
||||
return (
|
||||
<Tag color={item.color} icon={item.icon}>
|
||||
{item.text}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const getProgressStatus = (status: string) => {
|
||||
const statusMap: Record<string, 'success' | 'exception' | 'normal'> = {
|
||||
normal: 'success',
|
||||
warning: 'normal',
|
||||
critical: 'exception',
|
||||
exceeded: 'exception',
|
||||
};
|
||||
return statusMap[status] || 'normal';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="配额使用情况">
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<div>
|
||||
<Row justify="space-between" style={{ marginBottom: 8 }}>
|
||||
<Col>
|
||||
<Space>
|
||||
<ProjectOutlined />
|
||||
<span>项目数量</span>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>{getStatusTag(quota.projects.status)}</Col>
|
||||
</Row>
|
||||
<div style={{ marginBottom: 4, color: '#666' }}>
|
||||
{quota.projects.used} / {quota.projects.limit} 个项目
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((quota.projects.used / quota.projects.limit) * 100)}
|
||||
status={getProgressStatus(quota.projects.status)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Row justify="space-between" style={{ marginBottom: 8 }}>
|
||||
<Col>
|
||||
<Space>
|
||||
<CloudOutlined />
|
||||
<span>存储空间</span>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>{getStatusTag(quota.storage.status)}</Col>
|
||||
</Row>
|
||||
<div style={{ marginBottom: 4, color: '#666' }}>
|
||||
{quota.storage.used_gb.toFixed(2)} / {quota.storage.limit_gb} GB
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((quota.storage.used_gb / quota.storage.limit_gb) * 100)}
|
||||
status={getProgressStatus(quota.storage.status)}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotaDisplay;
|
||||
@@ -1,359 +0,0 @@
|
||||
/* V21 业务组件统一样式 */
|
||||
|
||||
/* ==================== 按钮 ==================== */
|
||||
.xx-primary-btn {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
border-radius: 14px !important;
|
||||
padding: 10px 20px !important;
|
||||
font-weight: 600 !important;
|
||||
box-shadow: 0 14px 26px rgba(79,70,229,0.22) !important;
|
||||
transition: all 0.2s !important;
|
||||
cursor: pointer;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.xx-primary-btn:hover {
|
||||
box-shadow: 0 18px 34px rgba(79,70,229,0.28) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.xx-ghost-btn {
|
||||
background: transparent !important;
|
||||
color: #4f46e5 !important;
|
||||
border: 2px solid #4f46e5 !important;
|
||||
border-radius: 14px !important;
|
||||
padding: 8px 18px !important;
|
||||
font-weight: 600 !important;
|
||||
transition: all 0.2s !important;
|
||||
cursor: pointer;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.xx-ghost-btn:hover {
|
||||
background: #eef2ff !important;
|
||||
}
|
||||
|
||||
/* ==================== 卡片 ==================== */
|
||||
.xx-card {
|
||||
background: rgba(255,255,255,0.94);
|
||||
border: 1px solid rgba(226,232,240,0.95);
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 24px 70px rgba(15,23,42,0.09);
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
|
||||
.xx-card:hover {
|
||||
box-shadow: 0 26px 64px rgba(15,23,42,0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ==================== 页面结构 ==================== */
|
||||
.xx-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.xx-page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.xx-page-head h2 {
|
||||
font-size: 26px;
|
||||
font-weight: 850;
|
||||
color: #0f172a;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.xx-page-head p {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ==================== 表格样式 ==================== */
|
||||
.xx-table-card {
|
||||
background: rgba(255,255,255,0.94);
|
||||
border: 1px solid rgba(226,232,240,0.95);
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 24px 70px rgba(15,23,42,0.09);
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 表格包装器 */
|
||||
.xx-table-wrapper {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ==================== 标签/Tag ==================== */
|
||||
.xx-tag {
|
||||
padding: 4px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.xx-tag-indigo {
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
border: 1px solid #c7d2fe;
|
||||
}
|
||||
|
||||
.xx-tag-success {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.xx-tag-warning {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
.xx-tag-error {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
/* ==================== 搜索栏 ==================== */
|
||||
.xx-search-bar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.xx-search-input {
|
||||
width: 100%;
|
||||
padding: 12px 18px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
transition: all 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xx-search-input:focus {
|
||||
border-color: #4f46e5;
|
||||
box-shadow: 0 0 0 4px rgba(79,70,229,0.1);
|
||||
}
|
||||
|
||||
/* ==================== Modal ==================== */
|
||||
.xx-modal .ant-modal-content {
|
||||
border-radius: 22px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.xx-modal .ant-modal-header {
|
||||
border-radius: 22px 22px 0 0;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.xx-modal .ant-modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.xx-modal .ant-modal-footer {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
/* ==================== 空状态 ==================== */
|
||||
.xx-empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.xx-empty-state-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ==================== 网格布局 ==================== */
|
||||
.xx-grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.xx-grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.xx-grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.xx-grid-2, .xx-grid-3, .xx-grid-4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 配额展示 ==================== */
|
||||
.xx-quota-item {
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.xx-quota-item:hover {
|
||||
border-color: #4f46e5;
|
||||
box-shadow: 0 8px 24px rgba(79,70,229,0.1);
|
||||
}
|
||||
|
||||
/* ==================== 进度条 ==================== */
|
||||
.xx-progress {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ==================== Ant Design 覆盖样式 ==================== */
|
||||
/* Table overrides */
|
||||
.ant-table-wrapper .ant-table-thead > tr > th {
|
||||
background: #f8fafc !important;
|
||||
font-weight: 700 !important;
|
||||
color: #0f172a !important;
|
||||
border-bottom: 2px solid #e2e8f0 !important;
|
||||
padding: 14px 16px !important;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-tbody > tr > td {
|
||||
padding: 14px 16px !important;
|
||||
border-bottom: 1px solid #f1f5f9 !important;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-tbody > tr:hover > td {
|
||||
background: #fafbfc !important;
|
||||
}
|
||||
|
||||
/* Card overrides */
|
||||
.ant-card {
|
||||
border-radius: 22px !important;
|
||||
border: 1px solid rgba(226,232,240,0.95) !important;
|
||||
}
|
||||
|
||||
.ant-card-head {
|
||||
border-bottom: 1px solid #e2e8f0 !important;
|
||||
min-height: 52px !important;
|
||||
padding: 0 24px !important;
|
||||
}
|
||||
|
||||
.ant-card-head-title {
|
||||
font-weight: 700 !important;
|
||||
font-size: 16px !important;
|
||||
color: #0f172a !important;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 20px 24px !important;
|
||||
}
|
||||
|
||||
/* Modal overrides */
|
||||
.ant-modal-content {
|
||||
border-radius: 22px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
padding: 20px 24px !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
font-weight: 700 !important;
|
||||
font-size: 18px !important;
|
||||
color: #0f172a !important;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 24px !important;
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
padding: 16px 24px !important;
|
||||
}
|
||||
|
||||
/* Button overrides */
|
||||
.ant-btn-primary {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
|
||||
border: none !important;
|
||||
border-radius: 14px !important;
|
||||
box-shadow: 0 14px 26px rgba(79,70,229,0.22) !important;
|
||||
height: auto !important;
|
||||
padding: 10px 20px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.ant-btn-primary:hover {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
|
||||
box-shadow: 0 18px 34px rgba(79,70,229,0.28) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Tag overrides */
|
||||
.ant-tag {
|
||||
border-radius: 10px !important;
|
||||
padding: 4px 12px !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
/* Select overrides */
|
||||
.ant-select-selector {
|
||||
border-radius: 14px !important;
|
||||
border-color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
.ant-select:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: #4f46e5 !important;
|
||||
}
|
||||
|
||||
.ant-select-focused .ant-select-selector {
|
||||
border-color: #4f46e5 !important;
|
||||
box-shadow: 0 0 0 3px rgba(79,70,229,0.1) !important;
|
||||
}
|
||||
|
||||
/* Input overrides */
|
||||
.ant-input {
|
||||
border-radius: 14px !important;
|
||||
border-color: #e2e8f0 !important;
|
||||
padding: 10px 14px !important;
|
||||
}
|
||||
|
||||
.ant-input:hover {
|
||||
border-color: #4f46e5 !important;
|
||||
}
|
||||
|
||||
.ant-input:focus {
|
||||
border-color: #4f46e5 !important;
|
||||
box-shadow: 0 0 0 3px rgba(79,70,229,0.1) !important;
|
||||
}
|
||||
|
||||
/* Progress overrides */
|
||||
.ant-progress-inner {
|
||||
background: #f1f5f9 !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
.ant-progress-bg {
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
/* Phase 1 Header 样式 + 手机端适配 */
|
||||
.xx-top-nav {
|
||||
height: 68px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.92);
|
||||
}
|
||||
|
||||
.xx-top-nav-inner {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.xx-brand {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: #0f172a;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-logo {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #818cf8, #4f46e5);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 12px 26px rgba(79, 70, 229, 0.24);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* 桌面端导航链接 */
|
||||
.xx-nav-links {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
color: #64748b;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.xx-nav-links button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: #64748b;
|
||||
padding: 6px 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 750;
|
||||
transition: color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-nav-links button:hover {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.xx-nav-links button.active {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
/* 右侧区域 */
|
||||
.xx-right-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-user-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.xx-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
/* 汉堡菜单按钮(默认隐藏) */
|
||||
.xx-hamburger {
|
||||
display: none;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
color: #64748b;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* 手机端导航抽屉内容 */
|
||||
.xx-mobile-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-mobile-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: #334155;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.xx-mobile-nav-item:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.xx-mobile-nav-item.active {
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.xx-mobile-nav-icon {
|
||||
font-size: 18px;
|
||||
width: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ===== 手机端响应式 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.xx-top-nav {
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.xx-top-nav-inner {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.xx-brand-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 隐藏桌面端导航 */
|
||||
.xx-nav-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 显示汉堡菜单 */
|
||||
.xx-hamburger {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 隐藏用户名 */
|
||||
.xx-username {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕平板:导航文字缩小 */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.xx-nav-links {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-nav-links button {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,30 @@
|
||||
/**
|
||||
* Phase 1 Header 重构
|
||||
* 扁平化导航菜单 + 手机端汉堡菜单
|
||||
* 顶部导航栏
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Avatar, Dropdown, Drawer, Space } from 'antd';
|
||||
import React from 'react';
|
||||
import { Layout, Button, Dropdown, Avatar, Space } from 'antd';
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
MenuOutlined,
|
||||
DashboardOutlined,
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
AudioOutlined,
|
||||
AppstoreOutlined,
|
||||
VideoCameraOutlined,
|
||||
HistoryOutlined,
|
||||
TrophyOutlined,
|
||||
ScanOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLogout } from '@/hooks/useAuth';
|
||||
import type { MenuProps } from 'antd';
|
||||
import './Header.css';
|
||||
|
||||
/** 导航项定义 */
|
||||
interface NavItem {
|
||||
key: string;
|
||||
label: string;
|
||||
path: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
/** 固定导航菜单 */
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ key: 'dashboard', label: '概览', path: '/dashboard', icon: <DashboardOutlined /> },
|
||||
{ key: 'assets', label: '素材库', path: '/assets', icon: <FileOutlined /> },
|
||||
{ key: 'titles', label: '标题库', path: '/titles', icon: <FileTextOutlined /> },
|
||||
{ key: 'voices', label: '配音库', path: '/voices', icon: <AudioOutlined /> },
|
||||
{ key: 'templates', label: '模板库', path: '/templates', icon: <AppstoreOutlined /> },
|
||||
{ key: 'generate', label: '一键生成', path: '/generate', icon: <VideoCameraOutlined /> },
|
||||
{ key: 'history', label: '任务历史', path: '/history', icon: <HistoryOutlined /> },
|
||||
{ key: 'products', label: '成品库', path: '/products', icon: <TrophyOutlined /> },
|
||||
{ key: 'duplication', label: '查重', path: '/duplication', icon: <ScanOutlined /> },
|
||||
];
|
||||
const { Header: AntHeader } = Layout;
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const sidebarCollapsed = useUIStore((state) => state.sidebarCollapsed);
|
||||
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const logoutMutation = useLogout();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
/** 用户下拉菜单 */
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'profile',
|
||||
@@ -62,12 +33,14 @@ const Header: React.FC = () => {
|
||||
onClick: () => navigate('/profile'),
|
||||
},
|
||||
{
|
||||
key: 'subscription',
|
||||
key: 'settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: '订阅管理',
|
||||
onClick: () => navigate('/subscription'),
|
||||
label: '账号设置',
|
||||
onClick: () => navigate('/profile/settings'),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
@@ -76,82 +49,31 @@ const Header: React.FC = () => {
|
||||
},
|
||||
];
|
||||
|
||||
/** 判断导航项是否激活 */
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/dashboard') {
|
||||
return location.pathname === '/' || location.pathname === '/dashboard';
|
||||
}
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="xx-top-nav">
|
||||
<div className="xx-top-nav-inner">
|
||||
<button className="xx-brand" type="button" onClick={() => navigate('/dashboard')}>
|
||||
<span className="xx-logo">🦐</span>
|
||||
<span className="xx-brand-text">小虾自动剪辑</span>
|
||||
</button>
|
||||
<AntHeader
|
||||
style={{
|
||||
padding: '0 24px',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={toggleSidebar}
|
||||
style={{ fontSize: '16px', width: 64, height: 64 }}
|
||||
/>
|
||||
|
||||
{/* 桌面端导航 */}
|
||||
<nav className="xx-nav-links">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={isActive(item.path) ? 'active' : ''}
|
||||
type="button"
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="xx-right-section">
|
||||
{/* 手机端汉堡菜单按钮 */}
|
||||
<button
|
||||
className="xx-hamburger"
|
||||
type="button"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
>
|
||||
<MenuOutlined />
|
||||
</button>
|
||||
|
||||
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
|
||||
<Space className="xx-user-menu">
|
||||
<Avatar className="xx-avatar" icon={<UserOutlined />} />
|
||||
<span className="xx-username">{user?.display_name || user?.username || '小虾用户'}</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 手机端侧边抽屉导航 */}
|
||||
<Drawer
|
||||
title="导航菜单"
|
||||
placement="left"
|
||||
onClose={() => setMobileMenuOpen(false)}
|
||||
open={mobileMenuOpen}
|
||||
width={260}
|
||||
className="xx-mobile-drawer"
|
||||
>
|
||||
<div className="xx-mobile-nav">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={`xx-mobile-nav-item ${isActive(item.path) ? 'active' : ''}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigate(item.path);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="xx-mobile-nav-icon">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Drawer>
|
||||
</header>
|
||||
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
|
||||
<Space style={{ cursor: 'pointer' }}>
|
||||
<Avatar icon={<UserOutlined />} />
|
||||
<span>{user?.display_name || user?.username}</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</AntHeader>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,34 +1,10 @@
|
||||
/* V21 主布局样式 */
|
||||
.xx-app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.main-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--bg, #f8fafc);
|
||||
color: var(--slate, #0f172a);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xx-app-content {
|
||||
flex: 1;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: 38px 24px 76px;
|
||||
}
|
||||
|
||||
/* V21 颜色变量 */
|
||||
:root {
|
||||
--indigo: #4f46e5;
|
||||
--slate: #0f172a;
|
||||
--muted: #64748b;
|
||||
--line: #e2e8f0;
|
||||
--bg: #f8fafc;
|
||||
--green: #10b981;
|
||||
--amber: #f59e0b;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
|
||||
.main-layout-content {
|
||||
margin: 24px 16px;
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
/**
|
||||
* 主布局组件
|
||||
* 包含 Header、Sidebar、Content 区域
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Layout } from 'antd';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
import Sidebar from './Sidebar';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import './MainLayout.css';
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
const sidebarCollapsed = useUIStore((state) => state.sidebarCollapsed);
|
||||
|
||||
return (
|
||||
<div className="xx-app-shell">
|
||||
<Header />
|
||||
<main className="xx-app-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
<Layout>
|
||||
<Header />
|
||||
<Content style={{ margin: '24px 16px', padding: 24, background: '#fff' }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 侧边栏导航
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Layout, Menu } from 'antd';
|
||||
import {
|
||||
HomeOutlined,
|
||||
AppstoreOutlined,
|
||||
TeamOutlined,
|
||||
CrownOutlined,
|
||||
UserOutlined,
|
||||
DashboardOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import type { MenuProps } from 'antd';
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '/workspaces',
|
||||
icon: <HomeOutlined />,
|
||||
label: '工作空间',
|
||||
onClick: () => navigate('/workspaces'),
|
||||
},
|
||||
{
|
||||
key: '/projects',
|
||||
icon: <AppstoreOutlined />,
|
||||
label: '项目',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: '/members',
|
||||
icon: <TeamOutlined />,
|
||||
label: '成员管理',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: '/subscription',
|
||||
icon: <CrownOutlined />,
|
||||
label: '订阅管理',
|
||||
onClick: () => navigate('/subscription'),
|
||||
},
|
||||
{
|
||||
key: '/admin',
|
||||
icon: <DashboardOutlined />,
|
||||
label: 'Admin(暂未开放)',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: '/profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人中心',
|
||||
onClick: () => navigate('/profile'),
|
||||
},
|
||||
];
|
||||
|
||||
// 根据当前路径选中菜单项
|
||||
const selectedKey = '/' + location.pathname.split('/')[1];
|
||||
|
||||
return (
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
trigger={null}
|
||||
width={200}
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 64,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{collapsed ? '🦐' : '小虾 SaaS'}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={menuItems}
|
||||
/>
|
||||
</Sider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user