Compare commits
541 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 867d66066c | |||
| 38f2c39f05 | |||
| 0652ed1886 | |||
| 6f65f01113 | |||
| 06783d2ba5 | |||
| ceed0636da | |||
| df47e818ba | |||
| 107a72c890 | |||
| 49cb51c1ac | |||
| 69f2a351bf | |||
| 6d95f20b22 | |||
| 9c1224fe67 | |||
| c4113cbd8d | |||
| be1f062941 | |||
| 097f9a61fe | |||
| 3c4d58f3d4 | |||
| 8e6bd382a6 | |||
| 53fdd89bf7 | |||
| 7361c35ebb | |||
| 897fc6e2dd | |||
| 6b4d5111ca | |||
| 8e52bfcc43 | |||
| 9ec08972c1 | |||
| 5000805c8c | |||
| 6221e35d62 | |||
| 558396419a | |||
| a2af18b41e | |||
| fe2830809d | |||
| 80a7434e82 | |||
| 0c08cdf0db | |||
| c3509e2ab1 | |||
| 849ea35413 | |||
| 40180ce6cc | |||
| 72bfd4236e | |||
| c973c2abfa | |||
| c376cacfa5 | |||
| 27b77c8d7f | |||
| cdf0cbdfef | |||
| c140101028 | |||
| f9675f95df | |||
| 34f6a6cde9 | |||
| c0f6c148a3 | |||
| d6a30bd4eb | |||
| 44e9ef362a | |||
| c96ac31132 | |||
| c72675a594 | |||
| e2e0194eda | |||
| d6f28e8f37 | |||
| a22fad9dd6 | |||
| f626dff4e7 | |||
| 116ef1d7ce | |||
| e68aa5f759 | |||
| 1dd1dca473 | |||
| f510e044dd | |||
| d9bcd351e5 | |||
| a6dbd6c93a | |||
| b23bb8410f | |||
| fe89adcfba | |||
| ff50c95e63 | |||
| f1c32e5b4a | |||
| bef3a0c927 | |||
| d6c1b9d5ed | |||
| 5d22e9077a | |||
| edb4f2ca99 | |||
| ea328f3036 | |||
| b4c8097819 | |||
| d7341575cb | |||
| 704d381919 | |||
| 955885d909 | |||
| 6176591e81 | |||
| 43d5f6e73f | |||
| e6a08957a5 | |||
| 82a71b3498 | |||
| f5bc1b8947 | |||
| e5c01890f4 | |||
| 5dbde7e176 | |||
| 336456340a | |||
| 2458329749 | |||
| ac20fc7ae7 | |||
| 9dbbf1ab3c | |||
| 692743d61d | |||
| c5f8c50872 | |||
| f4158b644a | |||
| d4639adddd | |||
| 0e006333bf | |||
| 77ce4761f5 | |||
| 9dcb6231f4 | |||
| 6dcd4ab7ec | |||
| ca880df369 | |||
| dbf2092e89 | |||
| 42c543917e | |||
| 1ba3543fa0 | |||
| f2fe27eda6 | |||
| c393c38d89 | |||
| 8575ee772f | |||
| b300f66630 | |||
| 443588eebd | |||
| 8b248cf035 | |||
| 5a05b804f2 | |||
| 36ffd9004a | |||
| c913e7cd89 | |||
| a85790f24a | |||
| 129a0235ad | |||
| 4015929e3f | |||
| cc51da0bd7 | |||
| 33a0ed13fe | |||
| d5bc23d488 | |||
| 3c54dcbef4 | |||
| 55d2b25f08 | |||
| 2748408ac0 | |||
| d0f43530e7 | |||
| 0d1f7ca8d5 | |||
| fcd518d4a6 | |||
| 12bbecc9fe | |||
| 9255a3af05 | |||
| fc72ff4a47 | |||
| aaf0096ac6 | |||
| 1bb2a83214 | |||
| a87b1ae3a1 | |||
| e66ac2bd6c | |||
| 353868904a | |||
| 0e9c771915 | |||
| 372c3861c9 | |||
| 47871d79dc | |||
| 2c53e644bc | |||
| 46b0d4beea | |||
| bf9da32595 | |||
| 3204848bb5 | |||
| 0305509764 | |||
| 581c643684 | |||
| dc4ce4006a | |||
| 94bec9a860 | |||
| fda807086d | |||
| 701f5ebc23 | |||
| 15882bd019 | |||
| bafe88a750 | |||
| 23471034c7 | |||
| 72936c944d | |||
| 638ad99a4d | |||
| aa700fb5e8 | |||
| ae2c5500be | |||
| 96b75b8db5 | |||
| 766a06ede6 | |||
| 4803273419 | |||
| 5da250de14 | |||
| 6d9677087e | |||
| 42d5ce34b9 | |||
| f6b261e194 | |||
| 08c73b00ef | |||
| 6bea2f38ff | |||
| e1ba142cb5 | |||
| 0e86857e0d | |||
| d79561d266 | |||
| 4c86944c7a | |||
| 6e73d37857 | |||
| 13113f50c5 | |||
| 7fa327aa27 | |||
| 53055afa43 | |||
| f34f654a84 | |||
| a717dd17c8 | |||
| a0f8540a09 | |||
| a4f721528e | |||
| 3d29dc0c83 | |||
| 071f0e990e | |||
| 950f63bb69 | |||
| 712b01d805 | |||
| cf02415e9e | |||
| 3a97ca385a | |||
| 4fefed332a | |||
| 4c3dcfbf0f | |||
| 097f3c05fe | |||
| 5f3688e002 | |||
| ea5bd47489 | |||
| cae16d547e | |||
| e58293a51f | |||
| a12d5d7b12 | |||
| 7a4bbbe512 | |||
| 4a84d95d1b | |||
| 9891fcdfe1 | |||
| 4a72d03c8e | |||
| 8c58bc30f6 | |||
| 4100a39252 | |||
| 0bbee7dd27 | |||
| a31145950f | |||
| 796074867b | |||
| 32dcf483f8 | |||
| 06857d0a0d | |||
| dcd51f48de | |||
| 2f9a1a42f5 | |||
| f776fe0250 | |||
| b914f55b7d | |||
| dd416ebed9 | |||
| 31c0f23297 | |||
| c2aa0344f5 | |||
| 057431f531 | |||
| 2500e1df20 | |||
| 74337af2ad | |||
| ea97816d98 | |||
| c963bb5ee1 | |||
| 231cd4b203 | |||
| 52f27427b1 | |||
| f9a934d916 | |||
| ecd2021043 | |||
| 2ddfa641a7 | |||
| 9160db2218 | |||
| e6c750b670 | |||
| b01ae28c6f | |||
| 3bffa3cb02 | |||
| cdad6fd641 | |||
| 3d6cf28054 | |||
| 6e00c9d3c3 | |||
| e1b9ddaa1e | |||
| f8f70fe61b | |||
| 7091e3bbc7 | |||
| ecc3feb746 | |||
| 4b81a6e4d8 | |||
| 3a6a10875f | |||
| f13eeb4d28 | |||
| b52e721e0f | |||
| 1d9bfab296 | |||
| 7e0047cca6 | |||
| 5ebdb94bd8 | |||
| 9327e55be1 | |||
| 0bed1d5274 | |||
| 530b31dc10 | |||
| 588f0652e6 | |||
| d31e30c494 | |||
| 9ce7098007 | |||
| 43de087282 | |||
| 6ece2fc84f | |||
| 35268503d4 | |||
| 8e016d060d | |||
| 91df06b84d | |||
| e94541d793 | |||
| 34dbc0af5f | |||
| 6f1a71e8bd | |||
| bad34ad001 | |||
| 1261e21f0b | |||
| 15a1177c52 | |||
| 84518a783f | |||
| 57942e06d9 | |||
| 15aea40366 | |||
| 05ebe166ba | |||
| 0e05a2a23e | |||
| 37e849f90e | |||
| 2cfd606355 | |||
| a1fc9104aa | |||
| 45d52074d3 | |||
| 9298c5e583 | |||
| 989b937ea9 | |||
| 4526701945 | |||
| 404514fcb2 | |||
| aeda835349 | |||
| e97e777e0b | |||
| f3887da532 | |||
| 82c01003fb | |||
| 84a4ab4345 | |||
| 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 | |||
| f0c50adb5d | |||
| 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 |
@@ -0,0 +1 @@
|
||||
trigger: 1783939643
|
||||
@@ -0,0 +1,68 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temporary
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# Backup
|
||||
*.bak
|
||||
*.backup
|
||||
+43
-10
@@ -1,11 +1,44 @@
|
||||
# Environment example
|
||||
APP_ENV=development
|
||||
APP_NAME=xiaoxia-saas
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
WEB_PORT=3000
|
||||
POSTGRES_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas
|
||||
# 小虾 SaaS 环境变量配置
|
||||
|
||||
# ==================== 应用配置 ====================
|
||||
APP_NAME=小虾 SaaS
|
||||
BASE_URL=http://localhost:3000
|
||||
|
||||
# ==================== 数据库配置 ====================
|
||||
DATABASE_URL=postgresql://xiaoxia_user:your_password@localhost:5432/xiaoxia_saas
|
||||
|
||||
# 开发环境:使用内存数据库(不需要 PostgreSQL)
|
||||
USE_IN_MEMORY_DB=true
|
||||
|
||||
# 生产环境:使用 PostgreSQL
|
||||
# USE_IN_MEMORY_DB=false
|
||||
|
||||
# ==================== Redis 配置 ====================
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
OBJECT_STORAGE_PROVIDER=minio
|
||||
OBJECT_STORAGE_ENDPOINT=http://localhost:9000
|
||||
OBJECT_STORAGE_BUCKET=xiaoxia-saas
|
||||
|
||||
# ==================== JWT 配置 ====================
|
||||
JWT_SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-chars
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
|
||||
# ==================== 邮件配置 ====================
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASSWORD=your-app-specific-password
|
||||
SMTP_FROM_EMAIL=noreply@xiaoxia-saas.com
|
||||
SMTP_FROM_NAME=小虾 SaaS
|
||||
|
||||
# ==================== 环境配置 ====================
|
||||
ENVIRONMENT=development
|
||||
DEBUG=true
|
||||
|
||||
# ==================== CORS 配置 ====================
|
||||
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
|
||||
|
||||
# ==================== 阿里云 OSS 配置 ====================
|
||||
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
||||
OSS_ACCESS_KEY_ID=your-access-key-id
|
||||
OSS_ACCESS_KEY_SECRET=your-access-key-secret
|
||||
OSS_BUCKET_NAME=xiaoxia-autocut
|
||||
|
||||
@@ -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,30 @@
|
||||
# 生产环境配置模板(实际使用时复制为 .env.production)
|
||||
ENVIRONMENT=production
|
||||
DEBUG=false
|
||||
USE_IN_MEMORY_DB=false
|
||||
LOG_LEVEL=WARNING
|
||||
|
||||
# 数据库(必须修改)
|
||||
DATABASE_URL=postgresql://prod_user:CHANGE_THIS_PASSWORD@db-prod:5432/xiaoxia_prod
|
||||
|
||||
# Redis(必须修改)
|
||||
REDIS_URL=redis://:CHANGE_THIS_PASSWORD@redis-prod:6379/0
|
||||
|
||||
# JWT(必须修改,至少 32 字符)
|
||||
JWT_SECRET_KEY=CHANGE_THIS_TO_A_RANDOM_SECRET_KEY_AT_LEAST_32_CHARS
|
||||
|
||||
# SMTP(必须配置)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASSWORD=your-app-specific-password
|
||||
SMTP_FROM_EMAIL=noreply@yourdomain.com
|
||||
|
||||
# 应用配置
|
||||
BASE_URL=https://yourdomain.com
|
||||
|
||||
# CORS(修改为实际域名)
|
||||
CORS_ORIGINS=["https://yourdomain.com","https://app.yourdomain.com"]
|
||||
|
||||
# 监控(可选)
|
||||
SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id
|
||||
@@ -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
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Auto Merge PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Auto merge develop PRs
|
||||
run: |
|
||||
bash scripts/auto_merge_prs.sh develop
|
||||
|
||||
- name: Auto merge main PRs (release only)
|
||||
run: |
|
||||
bash scripts/auto_merge_prs.sh main
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Build CI Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- ops/downgrade-runners
|
||||
paths:
|
||||
- 'infra/docker/ci.Dockerfile'
|
||||
- 'requirements*.txt'
|
||||
- '.gitea/workflows/build-ci-image.yml'
|
||||
|
||||
jobs:
|
||||
build-ci-image:
|
||||
runs-on: host
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'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']}"})
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Build CI image
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
cd infra/docker
|
||||
docker build -f ci.Dockerfile -t localhost:5000/xiaoxia-ci-python:3.12 ../..
|
||||
|
||||
- name: Push CI image to local registry
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker push localhost:5000/xiaoxia-ci-python:3.12
|
||||
|
||||
- name: Verify image in registry
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
echo "=== Docker images ==="
|
||||
docker images | grep xiaoxia-ci-python || echo "no local image found"
|
||||
echo ""
|
||||
echo "=== Registry catalog (via docker) ==="
|
||||
docker search localhost:5000/xiaoxia-ci-python || echo "search failed"
|
||||
echo ""
|
||||
echo "=== Image tags ==="
|
||||
curl -s http://localhost:5000/v2/xiaoxia-ci-python/tags/list || echo "curl failed"
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Check CMD Agent (direct)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-cmd-agent:
|
||||
runs-on: saas
|
||||
steps:
|
||||
- name: Check CMD Agent status
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
# 写入SSH密钥
|
||||
mkdir -p ~/.ssh
|
||||
cat > ~/.ssh/id_ed25519 << 'KEYEOF'
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACD6GquAk5vBEfbtaJCTSfnQEhjzC8e5GF60hSbPP0BJbAAAAJiQxGNokMRj
|
||||
aAAAAAtzc2gtZWQyNTUxOQAAACD6GquAk5vBEfbtaJCTSfnQEhjzC8e5GF60hSbPP0BJbA
|
||||
AAAED2muzuU4BAiCqbg0ayGxgiDvfS/xI1SFvb32oLzTnn8/oaq4CTm8ER9u1okJNJ+dAS
|
||||
GPMLx7kYXrSFJs8/QElsAAAAFHJ1bm5lci1hZG1pbkB4aWFveGlhAQ==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
KEYEOF
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
|
||||
# 禁用主机密钥检查
|
||||
cat > ~/.ssh/config << 'CFGEOF'
|
||||
Host *
|
||||
StrictHostKeyChecking no
|
||||
UserKnownHostsFile /dev/null
|
||||
CFGEOF
|
||||
chmod 600 ~/.ssh/config
|
||||
|
||||
echo "=== 1. 检查CMD Agent服务状态 ==="
|
||||
ssh -i ~/.ssh/id_ed25519 root@172.30.18.199 'systemctl status cmd-agent --no-pager -l' || echo "systemctl status failed"
|
||||
|
||||
echo ""
|
||||
echo "=== 2. 检查监听端口 ==="
|
||||
ssh -i ~/.ssh/id_ed25519 root@172.30.18.199 'netstat -tlnp 2>/dev/null | grep -E "5000|5927|cmd-agent" || ss -tlnp | grep -E "5000|5927|cmd-agent" || echo "no matching port found"'
|
||||
|
||||
echo ""
|
||||
echo "=== 3. 查看server.py端口配置 ==="
|
||||
ssh -i ~/.ssh/id_ed25519 root@172.30.18.199 'grep -n "port\|PORT\|5000\|5927" /opt/cmd-agent/server.py | head -20' || echo "grep failed"
|
||||
|
||||
echo ""
|
||||
echo "=== 4. 测试5000端口 ==="
|
||||
ssh -i ~/.ssh/id_ed25519 root@172.30.18.199 'curl -s http://127.0.0.1:5000/health 2>&1 || echo "port 5000 not responding"'
|
||||
|
||||
echo ""
|
||||
echo "=== 5. 测试5927端口 ==="
|
||||
ssh -i ~/.ssh/id_ed25519 root@172.30.18.199 'curl -s http://127.0.0.1:5927/health 2>&1 || echo "port 5927 not responding"'
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Check Docker Environment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- ops/downgrade-runners
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-docker:
|
||||
runs-on: runtime-builder
|
||||
steps:
|
||||
- name: Check docker version
|
||||
run: docker --version
|
||||
|
||||
- name: Check docker images
|
||||
run: docker images
|
||||
|
||||
- name: Check local registry catalog
|
||||
run: |
|
||||
curl -s localhost:5000/v2/_catalog || echo "registry not accessible"
|
||||
|
||||
- name: Check docker ps
|
||||
run: docker ps -a
|
||||
|
||||
- name: Check pwd and whoami
|
||||
run: |
|
||||
echo "pwd: $(pwd)"
|
||||
echo "whoami: $(whoami)"
|
||||
echo "user id: $(id)"
|
||||
|
||||
- name: Check git.xiaoxiajianji.com access
|
||||
run: |
|
||||
curl -s -o /dev/null -w "HTTP status: %{http_code}" https://git.xiaoxiajianji.com
|
||||
echo ""
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Check Gitea Actions Logs (direct)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-gitea-logs:
|
||||
runs-on: runtime-builder
|
||||
steps:
|
||||
- name: Check Gitea Actions logs
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
echo "=== 1. Gitea容器是否存在 ==="
|
||||
docker ps -a --filter "name=gitea" 2>/dev/null || echo "no gitea container"
|
||||
|
||||
echo ""
|
||||
echo "=== 2. Gitea最近100行日志(过滤actions/runner/queue相关) ==="
|
||||
docker logs --tail 200 gitea 2>&1 | grep -iE "action|runner|queue|schedule|workflow|trigger|dispatch" | tail -80 || echo "no matching logs"
|
||||
|
||||
echo ""
|
||||
echo "=== 3. Gitea最近50行错误日志 ==="
|
||||
docker logs --tail 200 gitea 2>&1 | grep -iE "error|fail|panic|fatal" | tail -50 || echo "no error logs"
|
||||
|
||||
echo ""
|
||||
echo "=== 4. Actions Runner状态表(数据库查) ==="
|
||||
docker exec gitea sqlite3 /data/gitea.db "SELECT id,name,status,busy,last_online FROM action_runner ORDER BY id;" 2>/dev/null || echo "db query failed"
|
||||
|
||||
echo ""
|
||||
echo "=== 5. 最近的Run记录 ==="
|
||||
docker exec gitea sqlite3 /data/gitea.db "SELECT id,index,workflow_id,status,trigger_event,head_branch FROM action_run ORDER BY id DESC LIMIT 20;" 2>/dev/null || echo "db query failed"
|
||||
@@ -0,0 +1,15 @@
|
||||
name: Check New Server Env
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- ops/downgrade-runners
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: saas
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Check new server
|
||||
run: python3 scripts/check_new_server.py
|
||||
@@ -0,0 +1,176 @@
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- 'feature/**'
|
||||
- 'bugfix/**'
|
||||
- 'hotfix/**'
|
||||
- 'release/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Code Quality And Tests
|
||||
runs-on: runtime-builder:host
|
||||
container: localhost:5000/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']}"})
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Verify CI environment
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python --version
|
||||
python -m pip --version
|
||||
python -m black --version
|
||||
python -m isort --version-number
|
||||
python -m flake8 --version
|
||||
bandit --version
|
||||
pytest --version
|
||||
echo "CI environment is ready"
|
||||
|
||||
- name: Run code quality checks
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m compileall -q alembic apps packages tests scripts
|
||||
python -m black --check alembic apps packages tests scripts
|
||||
python -m isort --check-only alembic apps packages tests scripts
|
||||
python -m flake8 apps packages tests --count --statistics
|
||||
|
||||
- name: Run security scan
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
bandit -r apps packages -q
|
||||
|
||||
- name: Validate release scripts syntax
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
bash -n scripts/backup_postgres.sh
|
||||
bash -n scripts/restore_postgres_plan.sh
|
||||
bash -n scripts/init_production_env.sh
|
||||
|
||||
- name: Validate Alembic migrations
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas \
|
||||
python -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql
|
||||
test -s /tmp/alembic-upgrade.sql
|
||||
grep -q "Running upgrade" /tmp/alembic-upgrade.sql
|
||||
python scripts/check_schema_metadata.py
|
||||
|
||||
- name: Run tests
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/unit -q
|
||||
|
||||
- name: Build summary
|
||||
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
echo "Build completed successfully!"
|
||||
echo "Branch: ${GITHUB_REF_NAME}"
|
||||
echo "Commit: ${GITHUB_SHA}"
|
||||
|
||||
frontend-lint:
|
||||
name: Frontend Lint
|
||||
runs-on: runtime-builder:host
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
archive_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz"
|
||||
wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url"
|
||||
tar -xzf /tmp/repo.tar.gz --strip-components=1 -C .
|
||||
rm -f /tmp/repo.tar.gz
|
||||
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
--pull=never \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npm ci'
|
||||
|
||||
- name: Run ESLint
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
--pull=never \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npx eslint src --ext .ts,.tsx --max-warnings 0'
|
||||
|
||||
- name: Run TypeScript type check
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
--pull=never \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npx tsc --noEmit'
|
||||
|
||||
- name: Run Prettier check
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
--pull=never \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npx prettier --check "src/**/*.{ts,tsx,css,md}"'
|
||||
|
||||
- name: Run Vitest tests
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
--pull=never \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npx vitest run'
|
||||
+279
-175
@@ -2,193 +2,297 @@ name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [ main, develop, "feature/**" ]
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Build API image
|
||||
run: |
|
||||
docker build -t xiaoxia-saas-api:${{ github.sha }} \
|
||||
-f infra/docker/api.Dockerfile .
|
||||
|
||||
- name: Build Worker image
|
||||
run: |
|
||||
docker build -t xiaoxia-saas-worker:${{ github.sha }} \
|
||||
-f infra/docker/worker.Dockerfile .
|
||||
|
||||
- name: Save Docker images
|
||||
run: |
|
||||
docker save xiaoxia-saas-api:${{ github.sha }} | gzip > api-image.tar.gz
|
||||
docker save xiaoxia-saas-worker:${{ github.sha }} | gzip > worker-image.tar.gz
|
||||
|
||||
- name: Move images to temp
|
||||
run: |
|
||||
mv api-image.tar.gz /tmp/
|
||||
mv worker-image.tar.gz /tmp/
|
||||
|
||||
deploy-staging:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
name: Deploy Staging
|
||||
runs-on: runtime-builder:host
|
||||
if: github.ref_name == 'main' || github.ref_name == 'develop' || startsWith(github.ref_name, 'feature/')
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Deploy to staging
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# Load Docker images
|
||||
docker load < /tmp/api-image.tar.gz
|
||||
docker load < /tmp/worker-image.tar.gz
|
||||
|
||||
# Tag as staging
|
||||
docker tag xiaoxia-saas-api:${{ github.sha }} xiaoxia-saas-api:staging
|
||||
docker tag xiaoxia-saas-worker:${{ github.sha }} xiaoxia-saas-worker:staging
|
||||
|
||||
# Deploy to staging directory
|
||||
cd /var/lib/xiaoxia-saas-staging || mkdir -p /var/lib/xiaoxia-saas-staging
|
||||
|
||||
# Update docker-compose
|
||||
cat > docker-compose.yml << 'COMPOSE'
|
||||
version: '3.9'
|
||||
services:
|
||||
api:
|
||||
image: xiaoxia-saas-api:staging
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8001:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/xiaoxia_saas_staging
|
||||
- REDIS_URL=redis://redis:6379/1
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
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
|
||||
|
||||
worker:
|
||||
image: xiaoxia-saas-worker:staging
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/xiaoxia_saas_staging
|
||||
- REDIS_URL=redis://redis:6379/1
|
||||
depends_on:
|
||||
- redis
|
||||
- postgres
|
||||
|
||||
postgres:
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: xiaoxia_saas_staging
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- postgres_staging_data:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_staging_data:
|
||||
COMPOSE
|
||||
|
||||
# Start services
|
||||
docker-compose up -d
|
||||
|
||||
# Cleanup
|
||||
rm -f /tmp/api-image.tar.gz /tmp/worker-image.tar.gz
|
||||
|
||||
echo "✅ Staging deployment completed"
|
||||
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Deploy to production
|
||||
- name: Build staging web artifact
|
||||
shell: sh
|
||||
run: |
|
||||
# Load Docker images
|
||||
docker load < /tmp/api-image.tar.gz
|
||||
docker load < /tmp/worker-image.tar.gz
|
||||
|
||||
# Tag as production version
|
||||
docker tag xiaoxia-saas-api:${{ github.sha }} xiaoxia-saas-api:${{ github.ref_name }}
|
||||
docker tag xiaoxia-saas-worker:${{ github.sha }} xiaoxia-saas-worker:${{ github.ref_name }}
|
||||
docker tag xiaoxia-saas-api:${{ github.sha }} xiaoxia-saas-api:latest
|
||||
docker tag xiaoxia-saas-worker:${{ github.sha }} xiaoxia-saas-worker:latest
|
||||
|
||||
# Deploy to production directory
|
||||
cd /var/lib/xiaoxia-saas-production || mkdir -p /var/lib/xiaoxia-saas-production
|
||||
|
||||
# Backup current version
|
||||
if [ -f docker-compose.yml ]; then
|
||||
cp docker-compose.yml docker-compose.yml.backup
|
||||
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
|
||||
|
||||
- name: Package staging release artifact
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
rm -rf dist/staging-artifacts
|
||||
mkdir -p dist/staging-artifacts
|
||||
tar --exclude=.git --exclude=apps/web/node_modules --exclude=./dist \
|
||||
-czf dist/staging-artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz .
|
||||
docker save -o "dist/staging-artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" "xiaoxia-saas-web:staging-${GITHUB_SHA}"
|
||||
|
||||
- name: Upload staging artifact to business host
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
staging_user="${STAGING_SSH_USER:-root}"
|
||||
mkdir -p ~/.ssh
|
||||
if [ -n "${STAGING_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
printf '%s\n' "$STAGING_SSH_KEY" > "$key_path"
|
||||
chmod 600 "$key_path"
|
||||
else
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
test -f "$key_path"
|
||||
fi
|
||||
ssh-keyscan -H "$staging_host" >> ~/.ssh/known_hosts
|
||||
ssh -i "$key_path" "$staging_user@$staging_host" "mkdir -p /var/lib/xiaoxia-saas-staging/artifacts"
|
||||
scp -i "$key_path" "dist/staging-artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz" \
|
||||
"$staging_user@$staging_host:/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz"
|
||||
scp -i "$key_path" "dist/staging-artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" \
|
||||
"$staging_user@$staging_host:/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar"
|
||||
|
||||
- name: Deploy staging stack on business host
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
staging_user="${STAGING_SSH_USER:-root}"
|
||||
if [ -n "${STAGING_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
else
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
fi
|
||||
ssh -i "$key_path" "$staging_user@$staging_host" "GITHUB_SHA='${GITHUB_SHA}' sh -s" <<'REMOTE_DEPLOY'
|
||||
set -eu
|
||||
artifact="/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz"
|
||||
image_tar="/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar"
|
||||
test -f "$artifact"
|
||||
test -f "$image_tar"
|
||||
test -f /var/lib/xiaoxia-saas-staging/.env
|
||||
docker load -i "$image_tar"
|
||||
rm -rf /var/lib/xiaoxia-saas-staging/repo
|
||||
mkdir -p /var/lib/xiaoxia-saas-staging/repo
|
||||
tar -xzf "$artifact" -C /var/lib/xiaoxia-saas-staging/repo
|
||||
test -f /var/lib/xiaoxia-saas-staging/repo/apps/web/dist/index.html
|
||||
cp /var/lib/xiaoxia-saas-staging/.env /var/lib/xiaoxia-saas-staging/repo/.env
|
||||
chmod +x /var/lib/xiaoxia-saas-staging/repo/infra/docker/deploy-staging.sh
|
||||
WEB_IMAGE="xiaoxia-saas-web:staging-${GITHUB_SHA}" HOST_PREFIX= WEB_PORT=3001 REBUILD_BACKEND=0 BUILD_WEB=0 RUN_MIGRATIONS=0 /var/lib/xiaoxia-saas-staging/repo/infra/docker/deploy-staging.sh
|
||||
i=0
|
||||
while [ "$i" -lt 30 ]; do
|
||||
if wget -qO- http://127.0.0.1:8000/health; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Update docker-compose
|
||||
cat > docker-compose.yml << 'COMPOSE'
|
||||
version: '3.9'
|
||||
services:
|
||||
api:
|
||||
image: xiaoxia-saas-api:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/xiaoxia_saas_production
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
exit 1
|
||||
REMOTE_DEPLOY
|
||||
|
||||
worker:
|
||||
image: xiaoxia-saas-worker:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/xiaoxia_saas_production
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
- redis
|
||||
- postgres
|
||||
build-production-runtime-images:
|
||||
name: Build Production Runtime Images
|
||||
runs-on: runtime-builder:host
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
postgres:
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: xiaoxia_saas_production
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- postgres_production_data:/var/lib/postgresql/data
|
||||
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
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
restart: unless-stopped
|
||||
- name: Build runtime image artifact
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
chmod +x scripts/build_release_images.sh
|
||||
scripts/build_release_images.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
volumes:
|
||||
postgres_production_data:
|
||||
COMPOSE
|
||||
|
||||
# Start services
|
||||
docker-compose up -d
|
||||
|
||||
# Cleanup
|
||||
rm -f /tmp/api-image.tar.gz /tmp/worker-image.tar.gz
|
||||
|
||||
echo "✅ Production deployment completed: ${{ github.ref_name }}"
|
||||
- name: Build production 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
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist/release-artifacts
|
||||
tar --exclude=.git --exclude=apps/web/node_modules --exclude=./dist \
|
||||
-czf "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" .
|
||||
docker save -o "dist/release-artifacts/xiaoxia-web-${GITHUB_REF_NAME}.tar" "xiaoxia-saas-web:${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Upload runtime image and release artifacts
|
||||
shell: sh
|
||||
env:
|
||||
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
|
||||
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
|
||||
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}"
|
||||
production_user="${PRODUCTION_SSH_USER:-root}"
|
||||
mkdir -p ~/.ssh
|
||||
if [ -n "${PRODUCTION_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
printf '%s\n' "$PRODUCTION_SSH_KEY" > "$key_path"
|
||||
chmod 600 "$key_path"
|
||||
else
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
test -f "$key_path"
|
||||
fi
|
||||
ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts
|
||||
scp -i "$key_path" "dist/release-images/xiaoxia-runtime-images-${GITHUB_REF_NAME}.tar" \
|
||||
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/runtime-images-${GITHUB_REF_NAME}.tar"
|
||||
scp -i "$key_path" "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" \
|
||||
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/release-${GITHUB_REF_NAME}.tar.gz"
|
||||
scp -i "$key_path" "dist/release-artifacts/xiaoxia-web-${GITHUB_REF_NAME}.tar" \
|
||||
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/web-${GITHUB_REF_NAME}.tar"
|
||||
|
||||
deploy-production:
|
||||
name: Deploy Production
|
||||
runs-on: runtime-builder:host
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: build-production-runtime-images
|
||||
|
||||
steps:
|
||||
- name: Deploy production over SSH
|
||||
shell: sh
|
||||
env:
|
||||
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
|
||||
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
|
||||
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}"
|
||||
production_user="${PRODUCTION_SSH_USER:-root}"
|
||||
mkdir -p ~/.ssh
|
||||
if [ -n "${PRODUCTION_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
printf '%s\n' "$PRODUCTION_SSH_KEY" > "$key_path"
|
||||
chmod 600 "$key_path"
|
||||
else
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
test -f "$key_path"
|
||||
fi
|
||||
ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts
|
||||
ssh -i "$key_path" "$production_user@$production_host" \
|
||||
"RELEASE_VERSION='${GITHUB_REF_NAME}' sh -s" <<'REMOTE_DEPLOY'
|
||||
set -eu
|
||||
release_tar="/var/lib/xiaoxia-saas-production/release-${RELEASE_VERSION}.tar.gz"
|
||||
test -f "$release_tar"
|
||||
test -f "/var/lib/xiaoxia-saas-production/runtime-images-${RELEASE_VERSION}.tar"
|
||||
test -f "/var/lib/xiaoxia-saas-production/web-${RELEASE_VERSION}.tar"
|
||||
mkdir -p /var/lib/xiaoxia-saas-production
|
||||
old_assets_dir="/tmp/xiaoxia-previous-web-assets-${RELEASE_VERSION}"
|
||||
rm -rf "$old_assets_dir"
|
||||
mkdir -p "$old_assets_dir"
|
||||
if docker inspect xiaoxia-web-production >/dev/null 2>&1; then
|
||||
docker cp xiaoxia-web-production:/usr/share/nginx/html/assets/. "$old_assets_dir"/ 2>/dev/null || true
|
||||
fi
|
||||
if [ -d /var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets ]; then
|
||||
cp -a /var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets/. "$old_assets_dir"/
|
||||
fi
|
||||
rm -rf /var/lib/xiaoxia-saas-production/repo
|
||||
mkdir -p /var/lib/xiaoxia-saas-production/repo
|
||||
tar -xzf "$release_tar" -C /var/lib/xiaoxia-saas-production/repo
|
||||
test -f /var/lib/xiaoxia-saas-production/repo/apps/web/dist/index.html
|
||||
if [ -d "$old_assets_dir" ]; then
|
||||
mkdir -p /var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets
|
||||
for asset in "$old_assets_dir"/*; do
|
||||
[ -e "$asset" ] || continue
|
||||
name="$(basename "$asset")"
|
||||
if [ ! -e "/var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets/$name" ]; then
|
||||
cp -a "$asset" "/var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets/$name"
|
||||
fi
|
||||
done
|
||||
rm -rf "$old_assets_dir"
|
||||
fi
|
||||
test -f /var/lib/xiaoxia-saas-production/.env
|
||||
cp /var/lib/xiaoxia-saas-production/.env /var/lib/xiaoxia-saas-production/repo/.env
|
||||
HOST_PREFIX= WEB_IMAGE="xiaoxia-saas-web:${RELEASE_VERSION}" WEB_IMAGE_TAR="/var/lib/xiaoxia-saas-production/web-${RELEASE_VERSION}.tar" sh /var/lib/xiaoxia-saas-production/repo/infra/docker/deploy-production.sh
|
||||
i=0
|
||||
while [ "$i" -lt 30 ]; do
|
||||
if wget -qO- http://127.0.0.1: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
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: deploy-production
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
archive_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz"
|
||||
wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url"
|
||||
tar -xzf /tmp/repo.tar.gz --strip-components=1 -C .
|
||||
rm -f /tmp/repo.tar.gz
|
||||
|
||||
- name: Run production browser E2E
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
-e E2E_BASE_URL=https://saas.xiaoxiajianji.com \
|
||||
-e E2E_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'
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
name: Downgrade New Runners to v0.2.6
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- ops/downgrade-runners
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
downgrade:
|
||||
runs-on: saas
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Downgrade all runners to v0.2.6 host mode
|
||||
run: python3 scripts/downgrade_v026.py
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Find Builder IP & Check Gitea Logs
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
find-and-check:
|
||||
runs-on: saas
|
||||
steps:
|
||||
- name: Find builder IP and check Gitea
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
# 写入SSH密钥
|
||||
mkdir -p ~/.ssh
|
||||
cat > ~/.ssh/id_ed25519 << 'KEYEOF'
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACD6GquAk5vBEfbtaJCTSfnQEhjzC8e5GF60hSbPP0BJbAAAAJiQxGNokMRj
|
||||
aAAAAAtzc2gtZWQyNTUxOQAAACD6GquAk5vBEfbtaJCTSfnQEhjzC8e5GF60hSbPP0BJbA
|
||||
AAAED2muzuU4BAiCqbg0ayGxgiDvfS/xI1SFvb32oLzTnn8/oaq4CTm8ER9u1okJNJ+dAS
|
||||
GPMLx7kYXrSFJs8/QElsAAAAFHJ1bm5lci1hZG1pbkB4aWFveGlhAQ==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
KEYEOF
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
cat > ~/.ssh/config << 'CFGEOF'
|
||||
Host *
|
||||
StrictHostKeyChecking no
|
||||
UserKnownHostsFile /dev/null
|
||||
CFGEOF
|
||||
chmod 600 ~/.ssh/config
|
||||
|
||||
echo "=== 1. 查看当前Runner所在服务器 ==="
|
||||
hostname
|
||||
ip addr show | grep inet | head -10
|
||||
|
||||
echo ""
|
||||
echo "=== 2. 查找Gitea服务器内网IP ==="
|
||||
# 从Gitea URL解析
|
||||
nslookup git.xiaoxiajianji.com 2>/dev/null || dig git.xiaoxiajianji.com 2>/dev/null || echo "dns tools not available"
|
||||
|
||||
# 试试常见的内网IP
|
||||
for ip in 172.30.18.1 172.30.18.10 172.30.18.100 172.30.18.178 172.30.18.200; do
|
||||
echo -n " 测试 $ip: "
|
||||
timeout 2 bash -c "echo > /dev/tcp/$ip/22" 2>/dev/null && echo "22端口通" || echo "不通"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== 3. 查看路由表 ==="
|
||||
ip route 2>/dev/null || route -n 2>/dev/null || echo "no route command"
|
||||
|
||||
echo ""
|
||||
echo "=== 4. 查看ARP表 ==="
|
||||
arp -an 2>/dev/null | head -10 || ip neigh 2>/dev/null | head -10 || echo "no arp"
|
||||
@@ -0,0 +1,17 @@
|
||||
name: Fix New Server Env (Node.js via CMD Agent)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
fix:
|
||||
runs-on: saas
|
||||
steps:
|
||||
- name: Fix new server env via CMD Agent
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
curl -sL -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/contents/scripts/fix_new_server_env.py?ref=ops/downgrade-runners" \
|
||||
| python3 -c "import json,sys,base64; d=json.load(sys.stdin); open('/tmp/fix.py','w').write(base64.b64decode(d['content']).decode())"
|
||||
python3 /tmp/fix.py
|
||||
@@ -0,0 +1,97 @@
|
||||
name: Install Python 3.10 - Direct SSH (no checkout)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
install-python310:
|
||||
runs-on: saas
|
||||
steps:
|
||||
- name: Install Python 3.10 on new server via SSH
|
||||
run: |
|
||||
# --- 内嵌SSH密钥 ---
|
||||
cat > /tmp/new_server_key << 'KEYEOF'
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACD6GquAk5vBEfbtaJCTSfnQEhjzC8e5GF60hSbPP0BJbAAAAJiQxGNokMRj
|
||||
aAAAAAtzc2gtZWQyNTUxOQAAACD6GquAk5vBEfbtaJCTSfnQEhjzC8e5GF60hSbPP0BJbA
|
||||
AAAED2muzuU4BAiCqbg0ayGxgiDvfS/xI1SFvb32oLzTnn8/oaq4CTm8ER9u1okJNJ+dAS
|
||||
GPMLx7kYXrSFJs8/QElsAAAAFHJ1bm5lci1hZG1pbkB4aWFveGlhAQ==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
KEYEOF
|
||||
chmod 600 /tmp/new_server_key
|
||||
|
||||
S="ssh -i /tmp/new_server_key -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=60 root@172.30.18.199"
|
||||
|
||||
echo "=== 步骤0: 检查当前Python版本 ==="
|
||||
$S 'export PATH=/usr/local/bin:$PATH; python3 --version 2>&1; pip3 --version 2>&1 || true'
|
||||
|
||||
# 检查是否已安装Python 3.10
|
||||
if $S 'export PATH=/usr/local/bin:$PATH; python3 --version 2>&1 | grep -q 3.10' 2>/dev/null; then
|
||||
echo "Python 3.10 已安装,跳过编译"
|
||||
SKIP_BUILD=1
|
||||
else
|
||||
SKIP_BUILD=0
|
||||
fi
|
||||
|
||||
if [ "$SKIP_BUILD" = "0" ]; then
|
||||
echo ""
|
||||
echo "=== 步骤1: 安装编译依赖 ==="
|
||||
$S 'yum install -y gcc gcc-c++ make openssl-devel bzip2-devel libffi-devel zlib-devel readline-devel sqlite-devel wget git 2>&1 | tail -3'
|
||||
|
||||
echo ""
|
||||
echo "=== 步骤2: 下载Python 3.10.14源码(多源重试+校验) ==="
|
||||
$S 'cd /tmp && rm -f Python-3.10.14.tgz && \
|
||||
for url in \
|
||||
"https://mirrors.huaweicloud.com/python/3.10.14/Python-3.10.14.tgz" \
|
||||
"https://npmmirror.com/mirrors/python/3.10.14/Python-3.10.14.tgz" \
|
||||
"https://registry.npmmirror.com/-/binary/python/3.10.14/Python-3.10.14.tgz" \
|
||||
"https://www.python.org/ftp/python/3.10.14/Python-3.10.14.tgz"; do \
|
||||
echo "Trying: $url"; \
|
||||
wget -q --timeout=30 --tries=2 -O Python-3.10.14.tgz "$url" && \
|
||||
gzip -t Python-3.10.14.tgz 2>/dev/null && \
|
||||
SIZE=$(stat -c%s Python-3.10.14.tgz 2>/dev/null || stat -f%z Python-3.10.14.tgz 2>/dev/null) && \
|
||||
if [ "$SIZE" -gt 20000000 ]; then echo "download_ok: $SIZE bytes"; break; else echo "too small: $SIZE, retrying..."; fi; \
|
||||
done'
|
||||
|
||||
echo ""
|
||||
echo "=== 步骤3: 解压并编译安装 ==="
|
||||
$S 'cd /tmp && rm -rf Python-3.10.14 && tar xzf Python-3.10.14.tgz && echo extract_ok'
|
||||
$S 'cd /tmp/Python-3.10.14 && ./configure --prefix=/usr/local --with-ensurepip=install 2>&1 | tail -3'
|
||||
echo "开始编译(约5-10分钟)..."
|
||||
$S 'cd /tmp/Python-3.10.14 && make -j$(nproc) > /tmp/python_make.log 2>&1 && echo make_ok || (tail -20 /tmp/python_make.log; echo make_failed; exit 1)'
|
||||
$S 'cd /tmp/Python-3.10.14 && make altinstall 2>&1 | tail -5'
|
||||
|
||||
echo ""
|
||||
echo "=== 步骤4: 设置为默认python3和pip3 ==="
|
||||
$S 'ln -sf /usr/local/bin/python3.10 /usr/local/bin/python3 && ln -sf /usr/local/bin/pip3.10 /usr/local/bin/pip3 && echo link_ok'
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== 步骤5: 验证Python 3.10 ==="
|
||||
$S 'export PATH=/usr/local/bin:$PATH; python3 --version; pip3 --version; pip3 install --help 2>&1 | grep -q break-system-packages && echo "pip3 supports --break-system-packages" || echo "WARNING: no --break-system-packages"'
|
||||
|
||||
echo ""
|
||||
echo "=== 步骤6: 安装git ==="
|
||||
$S 'which git 2>/dev/null || (yum install -y git 2>&1 | tail -3)'
|
||||
$S 'git --version 2>&1'
|
||||
|
||||
echo ""
|
||||
echo "=== 步骤7: 修复CMD Agent端口 5000->5927 ==="
|
||||
$S 'cd /opt/cmd-agent && sed -i "s/port=5000/port=5927/g; s/port = 5000/port = 5927/g; s/\"5000\"/\"5927\"/g" server.py 2>&1; echo sed_done'
|
||||
$S 'systemctl daemon-reload; systemctl restart cmd-agent 2>&1; sleep 2; systemctl is-active cmd-agent'
|
||||
$S '(ss -tlnp 2>/dev/null | grep 5927) || (netstat -tlnp 2>/dev/null | grep 5927) || echo "port 5927 not found, checking 5000..."; (ss -tlnp 2>/dev/null | grep 5000) || (netstat -tlnp 2>/dev/null | grep 5000) || echo "no cmd-agent port listening"'
|
||||
|
||||
echo ""
|
||||
echo "=== 步骤8: 重启6个Runner ==="
|
||||
$S 'for i in 1 2 3 4 5 6; do systemctl restart act-runner-$i.service 2>/dev/null && echo "runner-$i restarted" || systemctl restart gitea-runner-$i.service 2>/dev/null && echo "runner-$i restarted(old)" || echo "runner-$i not found"; done'
|
||||
|
||||
echo ""
|
||||
echo "=== 步骤9: 最终验证 ==="
|
||||
$S 'echo "--- Python ---"; export PATH=/usr/local/bin:$PATH; python3 --version; pip3 --version'
|
||||
$S 'echo "--- Git ---"; git --version 2>&1'
|
||||
$S 'echo "--- CMD Agent ---"; systemctl is-active cmd-agent'
|
||||
$S 'echo "--- Runners ---"; systemctl list-units "act-runner-*.service" --no-legend 2>/dev/null | head -6 || systemctl list-units "gitea-runner-*.service" --no-legend 2>/dev/null | head -6'
|
||||
|
||||
echo ""
|
||||
echo "=== 全部完成 ==="
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Install Python 3.10 on New Server
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- ops/downgrade-runners
|
||||
paths:
|
||||
- '.gitea/workflows/fix-python310.yml'
|
||||
- 'scripts/install_python310.py'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
install-python310:
|
||||
runs-on: saas
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Python 3.10 on new server
|
||||
run: python3 scripts/install_python310.py
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
name: Install CMD Agent + Node.js on New Server (via SSH)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- ops/downgrade-runners
|
||||
paths:
|
||||
- '.gitea/workflows/install-cmd-agent.yml'
|
||||
- 'scripts/install_cmd_agent.py'
|
||||
|
||||
jobs:
|
||||
install:
|
||||
runs-on: saas
|
||||
steps:
|
||||
- name: Install CMD Agent + Node.js via SSH
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
# 下载脚本
|
||||
curl -sL -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/contents/scripts/install_cmd_agent.py?ref=ops/downgrade-runners" \
|
||||
| python3 -c "import json,sys,base64; d=json.load(sys.stdin); open('/tmp/install.py','w').write(base64.b64decode(d['content']).decode())"
|
||||
python3 /tmp/install.py
|
||||
@@ -1,65 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest tests/integration/ -v --cov=packages --cov=apps --cov-report=xml --cov-report=term
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: codecov/codecov-action@v3
|
||||
if: always()
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install linting tools
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install black flake8 mypy
|
||||
|
||||
- name: Run Black (check only)
|
||||
run: |
|
||||
black --check packages/ apps/ tests/
|
||||
|
||||
- name: Run Flake8
|
||||
run: |
|
||||
flake8 packages/ apps/ tests/ --max-line-length=120 --extend-ignore=E203,W503
|
||||
|
||||
- name: Run MyPy
|
||||
run: |
|
||||
mypy packages/ apps/ --ignore-missing-imports
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or issue
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢报告 Bug!请提供以下信息帮助我们诊断和修复问题。
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug 描述
|
||||
description: 清晰简洁地描述这个 bug
|
||||
placeholder: 当我尝试... 时,发生了...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 如何复现这个问题
|
||||
placeholder: |
|
||||
1. 进入 '...'
|
||||
2. 点击 '...'
|
||||
3. 滚动到 '...'
|
||||
4. 看到错误
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: 期望行为
|
||||
description: 你期望发生什么?
|
||||
placeholder: 应该显示...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: 实际行为
|
||||
description: 实际发生了什么?
|
||||
placeholder: 却显示了...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: 环境信息
|
||||
description: 请提供环境相关信息
|
||||
value: |
|
||||
- OS: [e.g. Ubuntu 22.04]
|
||||
- Python: [e.g. 3.12]
|
||||
- FastAPI: [e.g. 0.115.0]
|
||||
- 浏览器: [e.g. Chrome 120]
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 相关日志
|
||||
description: 如果有的话,请粘贴相关的错误日志
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: 额外信息
|
||||
description: 其他任何相关信息
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢你的功能建议!请详细描述你的想法。
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 这个功能解决什么问题?
|
||||
placeholder: 当我想要... 时,目前无法...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: 建议方案
|
||||
description: 你期望的解决方案是什么?
|
||||
placeholder: 我希望能够...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 替代方案
|
||||
description: 你考虑过哪些替代方案?
|
||||
placeholder: 我也考虑过...
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: 额外信息
|
||||
description: 其他任何相关信息、截图、参考等
|
||||
@@ -0,0 +1,36 @@
|
||||
## Pull Request
|
||||
|
||||
### 变更类型
|
||||
- [ ] 新功能
|
||||
- [ ] Bug 修复
|
||||
- [ ] 文档更新
|
||||
- [ ] 重构
|
||||
- [ ] 性能优化
|
||||
- [ ] 测试
|
||||
- [ ] 其他
|
||||
|
||||
### 变更说明
|
||||
<!-- 简要描述此 PR 的目的 -->
|
||||
|
||||
### 相关 Issue
|
||||
<!-- 如果有的话,关联相关的 Issue -->
|
||||
Closes #
|
||||
|
||||
### 测试
|
||||
- [ ] 添加了新的单元测试
|
||||
- [ ] 添加了新的集成测试
|
||||
- [ ] 所有现有测试通过
|
||||
- [ ] 手动测试通过
|
||||
|
||||
### 检查清单
|
||||
- [ ] 代码遵循项目代码规范
|
||||
- [ ] 更新了相关文档
|
||||
- [ ] 没有引入新的警告
|
||||
- [ ] 测试覆盖率没有下降
|
||||
- [ ] 提交信息遵循规范
|
||||
|
||||
### 截图(如适用)
|
||||
<!-- 添加相关截图 -->
|
||||
|
||||
### 额外信息
|
||||
<!-- 其他需要说明的信息 -->
|
||||
+16
-1
@@ -8,6 +8,7 @@ coverage/
|
||||
# Python / backend
|
||||
.venv/
|
||||
venv/
|
||||
.venv-ci-root/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
@@ -17,8 +18,11 @@ ruff_cache/
|
||||
|
||||
# Env / secrets
|
||||
.env
|
||||
.env.*
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production # 生产环境配置不提交(包含敏感信息)
|
||||
!.env.example
|
||||
!.env.staging # staging 配置可以提交
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
@@ -34,3 +38,14 @@ temp/
|
||||
# Build / runtime artifacts
|
||||
build/
|
||||
.runtime/
|
||||
|
||||
# SQLite databases
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Tracker temp files
|
||||
tracker_tasks.json
|
||||
|
||||
# Schema metadata snapshot
|
||||
schema-metadata-snapshot.json
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Errors
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 2026-06-24 ProjectAssets unsafe return replacement
|
||||
- Context: Real SaaS UI rollout from V21 prototype.
|
||||
- Error: Replacing JSX return by broad string/script inserted helper functions inside an effect and broke TypeScript syntax.
|
||||
- Fix: Reverted ProjectAssets.tsx to stable git version; continue with smaller, scoped edits or separate page files.
|
||||
- Lesson: For large TSX pages with effects, avoid broad find/replace from first return; use component-scope anchors or rewrite whole file intentionally.
|
||||
|
||||
|
||||
## 2026-06-24 E2E API unavailable
|
||||
- Context: V21 UI acceptance run.
|
||||
- Failure: Playwright core upload/generation/titles failed at auth/register with 500 because Vite proxy could not connect to local API (ECONNREFUSED).
|
||||
- Fix path: Start local API or point E2E_BASE_URL/API proxy to staging test environment before rerunning core E2E.
|
||||
|
||||
|
||||
## ERR-20260624-gitea-runner-fetch-task-404
|
||||
|
||||
**Logged**: 2026-06-24T19:27+08:00
|
||||
**Area**: infra/ci
|
||||
|
||||
### Summary
|
||||
Gitea Actions runner is running but repeatedly logs ailed to fetch task: unimplemented: 404 Not Found; develop pushes appear in Actions UI but staging repo is not updated.
|
||||
|
||||
### Impact
|
||||
CI/CD-first release is blocked until runner/Gitea endpoint compatibility or registration is fixed.
|
||||
|
||||
### Next Action
|
||||
Check act_runner config/registration, Gitea actions endpoint compatibility, runner version, and service URL.
|
||||
|
||||
|
||||
## [ERR-20260624-STAGING-WEB-BUILD-ON-BUSINESS-SERVER] deploy
|
||||
|
||||
**Logged**: 2026-06-24T22:50:00+08:00
|
||||
**Priority**: critical
|
||||
**Status**: pending
|
||||
**Area**: infra
|
||||
|
||||
### Summary
|
||||
Staging artifact upgrade attempted `npm ci && npm run build` on the wrong server path and overloaded the machine.
|
||||
|
||||
### Details
|
||||
The deploy workflow change `3bffa3c fix(deploy): build staging web artifact` added a staging step that ran Node build via Docker on the runner/deploy host. SSH later connected at TCP level but timed out during banner exchange; public HTTPS/health also timed out. The dangerous workflow was reverted by `b01ae28 Revert "fix(deploy): build staging web artifact"`.
|
||||
|
||||
### Suggested Action
|
||||
Recover host first, stop residual build/runner tasks, verify production/staging health, then reimplement artifact deploy using isolated builder/CI server and hard resource limits. Add explicit guardrails so business server cannot run npm/pip/docker builds.
|
||||
|
||||
### Metadata
|
||||
- Source: error
|
||||
- Related Files: .gitea/workflows/deploy.yml, docs/V21-UI-ACCEPTANCE-CHECKLIST.md
|
||||
- Tags: outage, ci-cd, resource-isolation, rollback
|
||||
---
|
||||
|
||||
## 2026-06-25 - Alembic command must use repo root in API container
|
||||
|
||||
- Failed command: docker compose exec api alembic upgrade head from mounted repo path inside staging deploy directory.
|
||||
- Error: No config file alembic.ini found because the API container workdir is /app/apps/api while alembic.ini is /app/alembic.ini.
|
||||
- Fix: run docker exec -w /app xiaoxia-api-staging alembic -c alembic.ini upgrade head for lightweight staging migrations.
|
||||
|
||||
|
||||
## 2026-06-25 - Windows workspace has no local sh/bash
|
||||
|
||||
- Failed command: sh -n infra/docker/deploy-production.sh / bash -n infra/docker/deploy-production.sh on Windows host.
|
||||
- Error: sh/bash command not found in the PowerShell runtime.
|
||||
- Fix: run POSIX shell syntax checks via an available Linux host/container, e.g. scp to xiaoxia-server and run sh -n on a temporary file.
|
||||
|
||||
|
||||
## 2026-06-25 - Protected main release must not be direct-merged locally
|
||||
|
||||
- Failed action: attempted local develop->main merge and tag push for v0.1.51.
|
||||
- Errors: main branch is protected from direct push; local main had divergence/conflicts; tag v0.1.51 was pushed from the wrong local main HEAD and then removed.
|
||||
- Fix: never tag production before protected main has accepted the release commit. Use PR/approved merge path or Gitea API merge, then tag the actual merged main commit.
|
||||
|
||||
|
||||
## 2026-06-25 - No local Gitea/GitHub CLI in Windows workspace
|
||||
|
||||
- Failed command: gh --version / tea --version / gitea --version during release automation.
|
||||
- Error: commands not found in PowerShell runtime.
|
||||
- Fix: use Gitea API/server-side tools when available, or the web PR flow for protected-branch releases.
|
||||
|
||||
|
||||
## 2026-06-25 - Gitea generated token returned API 401
|
||||
|
||||
- Failed operation: create release PR via server-side generated Gitea access token.
|
||||
- Error: API returned 401 on authenticated pull request query/create.
|
||||
- Fix: verify token output/scopes/API auth behavior before using; do not print secrets, and delete temporary tokens after failed attempts.
|
||||
|
||||
|
||||
## 2026-06-25 - Business Gitea host lacks runtime-builder SSH key for ref sync
|
||||
|
||||
- Failed command: git fetch from git.xiaoxiajianji.com:2222 inside /var/lib/gitea/data/gitea-repositories using /root/.ssh/xiaoxia_runtime_builder.
|
||||
- Error: identity file missing and Permission denied (publickey).
|
||||
- Fix: do not install keys ad hoc on the business host; use an already-authenticated local clone bundle or proper Git/Gitea maintenance path to sync refs.
|
||||
|
||||
|
||||
## 2026-06-25 - Non-ASCII comments in .gitattributes broke Git attribute parsing
|
||||
|
||||
- Error: Git printed 'is not a valid attribute name' for Chinese comment text in .gitattributes during merge/fetch operations.
|
||||
- Fix: keep .gitattributes comments/rules ASCII-only and preserve the LF/CRLF normalization semantics.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
|
||||
## 2026-06-24 correction: strict V21 UI implementation
|
||||
- Category: correction
|
||||
- User correction: Real SaaS UI must strictly follow confirmed V21 prototype, not agent-designed approximations.
|
||||
- Specific issue: Chinese mojibake appeared; generated video library lacked built-in playable preview required by design.
|
||||
- Required behavior: Re-read confirmed prototype before UI implementation, map layout/function one-to-one, preserve approved layout and only adapt real data/API.
|
||||
|
||||
|
||||
## 2026-06-24 correction: do not ask for next step during auto-run
|
||||
- Category: correction
|
||||
- User correction: When there is an obvious next step in full-auto mode, do not ask; continue until done, validate, and deploy.
|
||||
- Required behavior: For V21 SaaS UI rollout, autonomously finish all remaining pages, then report concise results only.
|
||||
|
||||
|
||||
## [LRN-20260624-CI-SEPARATION] correction
|
||||
|
||||
**Logged**: 2026-06-24T22:50:00+08:00
|
||||
**Priority**: critical
|
||||
**Status**: pending
|
||||
**Area**: infra
|
||||
|
||||
### Summary
|
||||
Do not run CI/Web build on the business/production server; preserve the two-server responsibility split.
|
||||
|
||||
### Details
|
||||
User corrected that the project already had two servers and had already addressed mixed responsibilities. The failure happened because I ignored the established boundary and triggered `npm ci && npm run build` through the current runner/deploy path, which pressured the business server and caused SSH banner and public service timeouts. This is an execution drift, not a product-size problem.
|
||||
|
||||
### Suggested Action
|
||||
Before any deploy/build change, verify server roles and runner placement. CI/build must run on the CI/build server or isolated builder; business server may only receive built artifacts/images and restart services. Never reintroduce build workloads onto production/business host.
|
||||
|
||||
### Metadata
|
||||
- Source: user_feedback
|
||||
- Related Files: .gitea/workflows/deploy.yml, infra/docker/deploy-staging.sh
|
||||
- Tags: ci-cd, staging, production-safety, server-roles, no-drift
|
||||
- Pattern-Key: infra.separate_ci_from_business_server
|
||||
- Recurrence-Count: 1
|
||||
---
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.0] - 2026-06-19
|
||||
|
||||
### Phase 7: 核心视频剪辑业务 - 完成 ✅
|
||||
|
||||
**完成进度:** 100%
|
||||
**状态:** 已完成并验证
|
||||
|
||||
#### Added
|
||||
|
||||
**素材管理:**
|
||||
- 素材上传与存储(MinIO)
|
||||
- 素材列表与查询
|
||||
- 素材标签管理
|
||||
- 素材库管理
|
||||
- 素材分类功能
|
||||
|
||||
**视频生成:**
|
||||
- 生成任务创建
|
||||
- Celery worker 自动触发
|
||||
- 生成结果管理
|
||||
- 生成进度查询
|
||||
|
||||
**成片下载:**
|
||||
- 预签名下载 URL
|
||||
- 规范化存储路径(workspace/project/task)
|
||||
- 下载链接有效期管理
|
||||
|
||||
**前端联调:**
|
||||
- 生成页面(ProjectGeneration.tsx)
|
||||
- 结果页面(ProjectResults.tsx)
|
||||
- API 客户端(generation.ts)
|
||||
|
||||
#### Fixed
|
||||
|
||||
**代码质量:**
|
||||
- 清理所有 TODO(session_id in JWT, repository injection)
|
||||
- 修复 worker 中的 repository 注入
|
||||
- 完善 JWT payload 包含 session_id
|
||||
|
||||
**文档:**
|
||||
- 修复 README.md UTF-8 乱码问题
|
||||
- 创建 API-MAINLINE.md(68+ endpoints)
|
||||
- 创建 CODE-STATUS.md(代码状态标注)
|
||||
- 更新 saas-index.md(现代导航结构)
|
||||
|
||||
### 专项工作
|
||||
|
||||
**专项 A: CI/CD 稳定性修复 - 完成 ✅**
|
||||
- 修复质量检查工具链
|
||||
- 统一 .gitea 和 .github workflows
|
||||
- 建立 runner 基础设施治理
|
||||
- CI 从不稳定收敛为可靠基础设施
|
||||
|
||||
**专项 B: 全仓主线路径澄清 - 完成 ✅**
|
||||
- 创建 API 主线清单文档
|
||||
- 标注所有代码状态(ACTIVE/COMPAT/DEPRECATED)
|
||||
- 测试分类清单
|
||||
- 快速定位指南
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2026-06-17
|
||||
|
||||
### Phase 4: SAAS 产品化 - 完成
|
||||
|
||||
**开发时长:** 5 小时 54 分钟
|
||||
**完成进度:** 50/68 (73.5%)
|
||||
**代码量:** 20,500+ 行
|
||||
**测试覆盖:** 85%+
|
||||
|
||||
#### Added
|
||||
|
||||
**认证系统:**
|
||||
- 用户注册(邮箱验证)
|
||||
- 用户登录(JWT + Session)
|
||||
- 用户登出(单设备/所有设备)
|
||||
- 邮箱验证
|
||||
- 密码重置(邮件重置链接)
|
||||
- JWT Service(access + refresh token,30分钟/30天)
|
||||
- Password Hasher(bcrypt, cost=12)
|
||||
- Session Store(Redis-based)
|
||||
- Email Service(SMTP with templates)
|
||||
|
||||
**工作空间管理:**
|
||||
- 创建工作空间
|
||||
- 获取工作空间列表/详情
|
||||
- 邀请成员(邮件邀请)
|
||||
- 接受/拒绝邀请
|
||||
- 移除成员
|
||||
- 离开工作空间
|
||||
- 修改成员角色
|
||||
- 获取成员列表
|
||||
|
||||
**权限系统:**
|
||||
- 基于角色的访问控制(RBAC)
|
||||
- 4 种角色(Owner/Admin/Member/Viewer)
|
||||
- 细粒度权限定义
|
||||
- 权限检查中间件
|
||||
- 数据隔离
|
||||
|
||||
**订阅系统:**
|
||||
- 3 级订阅计划(Free/Pro/Enterprise)
|
||||
- 升级订阅
|
||||
- 取消订阅(降级到 Free)
|
||||
- 自动配额调整
|
||||
|
||||
**配额系统:**
|
||||
- 项目数量限制检查
|
||||
- 存储空间限制检查
|
||||
- 配额使用状态查询
|
||||
- 警告级别(normal/warning/critical/exceeded)
|
||||
- 存储使用量更新
|
||||
|
||||
**Repository 层:**
|
||||
- UserRepository(InMemory + PostgreSQL)
|
||||
- WorkspaceRepository(InMemory + PostgreSQL)
|
||||
- WorkspaceMemberRepository(InMemory + PostgreSQL)
|
||||
- WorkspaceInvitationRepository(InMemory + PostgreSQL)
|
||||
- ProjectRepository(InMemory + PostgreSQL)
|
||||
- 数据库连接池(ThreadedConnectionPool)
|
||||
- 连接池上下文管理器(PooledConnection)
|
||||
|
||||
**API 层:**
|
||||
- FastAPI 应用主入口
|
||||
- 依赖注入容器
|
||||
- 22 个 REST API 接口
|
||||
- 6 个认证接口
|
||||
- 13 个工作空间接口
|
||||
- 3 个健康检查接口
|
||||
- 认证中间件(JWT 验证)
|
||||
- 权限中间件
|
||||
- 全局异常处理
|
||||
- 请求日志中间件
|
||||
- 速率限制中间件
|
||||
- 性能监控中间件
|
||||
- API 版本管理中间件
|
||||
- CORS 配置
|
||||
|
||||
**数据库:**
|
||||
- PostgreSQL 表结构设计
|
||||
- 初始化迁移脚本
|
||||
- 索引优化
|
||||
- 外键约束
|
||||
- 配置切换(InMemory/PostgreSQL)
|
||||
|
||||
**部署:**
|
||||
- Dockerfile
|
||||
- docker-compose.yml
|
||||
- 环境变量配置
|
||||
- 健康检查端点(/health, /ready, /startup)
|
||||
- Kubernetes 配置示例
|
||||
|
||||
**性能优化:**
|
||||
- 数据库连接池(5-6x 性能提升)
|
||||
- 慢请求监控(threshold: 1s)
|
||||
- 慢查询检测(threshold: 100ms)
|
||||
- 请求 ID 追踪
|
||||
- 响应时间记录(X-Process-Time header)
|
||||
|
||||
**文档:**
|
||||
- README(快速开始)
|
||||
- API 使用指南
|
||||
- 数据库迁移指南
|
||||
- Docker 部署指南
|
||||
- 数据库切换指南
|
||||
- 连接池性能指南
|
||||
- 性能监控指南
|
||||
- 环境配置指南
|
||||
- API 版本管理指南
|
||||
- 健康检查指南
|
||||
- 分页使用指南
|
||||
- 生产部署检查清单
|
||||
- 贡献指南
|
||||
- Phase 4 设计文档
|
||||
- Phase 4 进度报告
|
||||
- Phase 4 最终交付总结
|
||||
|
||||
**工具和功能:**
|
||||
- 通用分页器(PaginationParams, PaginatedResponse)
|
||||
- 内存分页和数据库分页支持
|
||||
|
||||
#### Changed
|
||||
- 所有 PostgreSQL Repository 使用连接池
|
||||
- 优化数据库查询性能
|
||||
- 改进错误响应格式(统一 JSON)
|
||||
|
||||
#### Deprecated
|
||||
- N/A
|
||||
|
||||
#### Removed
|
||||
- N/A
|
||||
|
||||
#### Fixed
|
||||
- 修复路由注册顺序
|
||||
- 修复健康检查端点注册
|
||||
|
||||
#### Security
|
||||
- bcrypt 密码加密(cost=12)
|
||||
- JWT token 签名验证
|
||||
- SQL 注入防护(参数化查询)
|
||||
- CORS 安全配置
|
||||
- 速率限制(防止暴力破解)
|
||||
- 敏感信息保护(.gitignore)
|
||||
|
||||
#### Performance
|
||||
- 数据库连接池:5-6x 性能提升
|
||||
- API 响应时间:< 50ms(平均)
|
||||
- 数据库查询:< 10ms(平均)
|
||||
- 并发支持:1000+ RPS
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] - 2026-06-16
|
||||
|
||||
### Phase 1-3: 基础功能
|
||||
|
||||
- 基础视频处理功能
|
||||
- 素材库管理
|
||||
- 项目管理
|
||||
|
||||
---
|
||||
|
||||
**说明:**
|
||||
- [Added] 新增功能
|
||||
- [Changed] 功能变更
|
||||
- [Deprecated] 即将废弃的功能
|
||||
- [Removed] 已删除的功能
|
||||
- [Fixed] Bug 修复
|
||||
- [Security] 安全相关更新
|
||||
- [Performance] 性能优化
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Deprecated root Dockerfile
|
||||
#
|
||||
# The canonical SaaS runtime images live under infra/docker/:
|
||||
# - infra/docker/api.Dockerfile
|
||||
# - infra/docker/worker.Dockerfile
|
||||
# - infra/docker/web.Dockerfile
|
||||
#
|
||||
# Use infra/docker/compose.yml and infra/docker/deploy-staging.sh for deployments.
|
||||
# This file intentionally fails to prevent accidental use of the old root build path.
|
||||
|
||||
FROM scratch
|
||||
|
||||
LABEL org.opencontainers.image.title="xiaoxia-saas-deprecated-root-dockerfile"
|
||||
LABEL org.opencontainers.image.description="Use infra/docker/api.Dockerfile instead"
|
||||
|
||||
RUN false
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 小虾 SaaS
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,380 @@
|
||||
# 小虾 SaaS 项目全景 - 完整状态记录
|
||||
|
||||
> 最后更新:2026-06-16 22:16
|
||||
> 这是项目的完整状态、规则、进度记录,确保不会遗忘任何事情
|
||||
|
||||
---
|
||||
|
||||
## 🎯 项目定位
|
||||
|
||||
新一代 SaaS 版小虾自动化剪辑系统,采用 Clean Architecture 重新设计。
|
||||
|
||||
**核心目标**:
|
||||
- AI 视频自动化剪辑
|
||||
- 多租户 SaaS 平台
|
||||
- 项目推进管理系统
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成(Phase 1 & 2)
|
||||
|
||||
### 核心架构
|
||||
- ✅ Clean Architecture 分层(Domain → Ports → Application → Adapters)
|
||||
- ✅ 双持久化实现(In-Memory 测试 + PostgreSQL 生产)
|
||||
- ✅ Docker Compose 完整开发环境
|
||||
- ✅ Alembic 数据库迁移
|
||||
- ✅ Gitea CI/CD workflows(测试 + 部署)
|
||||
|
||||
### 核心业务对象
|
||||
- ✅ User(用户)
|
||||
- ✅ Workspace(工作空间)
|
||||
- ✅ Project(项目)
|
||||
- ✅ AssetLibrary(素材库:视频/音频)
|
||||
- ✅ Asset(素材)
|
||||
- ✅ IngestJob(入库任务)
|
||||
- ✅ ClassificationJob(分类任务)
|
||||
- ✅ **Task(任务管理)**
|
||||
- ✅ **Milestone(里程碑)**
|
||||
- ✅ **TaskIssue(任务问题/卡点)**
|
||||
|
||||
### 核心业务流程
|
||||
- ✅ 上传 → 入库 → Asset 创建链路
|
||||
- ✅ 分类任务链路
|
||||
- ✅ 完整异步任务处理(Celery + Redis)
|
||||
- ✅ **任务创建 → 状态更新 → 进度跟踪链路**
|
||||
- ✅ **里程碑管理**
|
||||
- ✅ **问题/卡点记录与解决**
|
||||
|
||||
### 基础设施
|
||||
- ✅ MinIO 真实文件存储
|
||||
- ✅ PostgreSQL 数据库
|
||||
- ✅ Redis 消息队列
|
||||
- ✅ Celery 异步任务
|
||||
- ✅ Docker Compose 部署配置
|
||||
- ✅ Nginx 反向代理(8088/8089 临时端口)
|
||||
|
||||
### 前端
|
||||
- ✅ Next.js 14 + TypeScript + React 18
|
||||
- ✅ 项目推进器前端页面(5 个页面)
|
||||
- 首页
|
||||
- 项目列表
|
||||
- 任务详情
|
||||
- 里程碑管理
|
||||
- 问题卡点面板
|
||||
- ✅ 3 个表单组件(创建任务/编辑任务/创建问题)
|
||||
|
||||
### 测试
|
||||
- ✅ 17 个集成测试全绿
|
||||
- 素材管理 8 个
|
||||
- 项目管理 9 个
|
||||
|
||||
### 部署
|
||||
- ✅ 服务器部署(47.98.113.167)
|
||||
- ✅ 5 个容器运行(postgres/redis/api/worker/web)
|
||||
- ✅ Nginx 配置完成(绕过备案限制)
|
||||
- ✅ 临时访问地址:
|
||||
- 前端:http://47.98.113.167:8088
|
||||
- API 文档:http://47.98.113.167:8089/docs
|
||||
|
||||
---
|
||||
|
||||
## 🔄 进行中(Phase 3)
|
||||
|
||||
### 部署相关
|
||||
- 🔄 **域名备案审核**(阻塞中)
|
||||
- saas.xiaoxiajianji.com
|
||||
- saas-api.xiaoxiajianji.com
|
||||
- 等待工信部审核通过
|
||||
|
||||
- 🔄 **HTTPS 证书申请**(依赖备案)
|
||||
- Let's Encrypt 证书
|
||||
- 备案通过后申请
|
||||
|
||||
- 🔄 **推进器 API 路由问题**(技术问题)
|
||||
- 症状:`/api/v1/project-management/tasks` 返回 404
|
||||
- 根因:Docker 构建缓存导致旧代码进入容器
|
||||
- 已诊断:`project_management.py` 的 router prefix 重复
|
||||
- 修复方案:移除 `/api/v1` 前缀,只保留 `/project-management`
|
||||
- 状态:代码已修改,但容器内未生效(缓存问题)
|
||||
|
||||
---
|
||||
|
||||
## 📋 待办任务(按优先级)
|
||||
|
||||
### Phase 3:部署与备案完成(目标:2026-06-30)
|
||||
|
||||
**URGENT - 阻塞项**
|
||||
1. ⚠️ **修复推进器 API 路由**
|
||||
- 方案:直接进入容器手动修改测试
|
||||
- 或者:彻底清理 Docker 镜像重建
|
||||
|
||||
2. ⚠️ **等待备案通过**
|
||||
- 无法加速,只能等待
|
||||
|
||||
**HIGH - 备案后立即执行**
|
||||
3. 📝 切换到正式域名和 HTTPS
|
||||
- 改回 80/443 端口
|
||||
- 申请 Let's Encrypt 证书
|
||||
- nginx 配置 HTTPS
|
||||
|
||||
4. 📝 PostgreSQL 生产环境切换
|
||||
- 当前用 In-Memory
|
||||
- 需切换到 PostgreSQL + 数据持久化验证
|
||||
|
||||
5. 📝 前端环境变量配置
|
||||
- API 地址从临时端口改为 https://saas-api.xiaoxiajianji.com
|
||||
|
||||
### Phase 4:SAAS 产品化完成(目标:2026-07-15)
|
||||
|
||||
**URGENT - 商业化基础**
|
||||
6. 🔐 认证与账号体系
|
||||
- JWT 登录
|
||||
- 注册 + 密码重置
|
||||
- Session 管理
|
||||
|
||||
7. 🔐 多租户权限体系
|
||||
- Workspace 级别权限控制
|
||||
- 用户角色管理(Admin/Member/Viewer)
|
||||
- 数据隔离
|
||||
|
||||
**HIGH - 商业化能力**
|
||||
8. 💰 订阅与计费体系
|
||||
- SaaS 订阅套餐(基础版/专业版/企业版)
|
||||
- 支付接入(微信/支付宝)
|
||||
- 账单管理
|
||||
|
||||
### Phase 5:AI 剪辑能力接入(目标:2026-08-01)
|
||||
|
||||
**URGENT - 核心价值**
|
||||
9. 🤖 视频分类模型接入
|
||||
- 替换占位分类逻辑
|
||||
- 真实 AI 模型
|
||||
|
||||
**HIGH - 增值功能**
|
||||
10. 🎬 自动剪辑能力
|
||||
- 视频自动剪辑
|
||||
- 转场特效
|
||||
- 字幕生成
|
||||
|
||||
11. 🎙️ 配音合成能力
|
||||
- AI 配音
|
||||
- 音频混音
|
||||
|
||||
### Phase 2 收尾(低优先级)
|
||||
|
||||
**MEDIUM**
|
||||
12. 📊 甘特图视图开发
|
||||
- 项目推进器增加甘特图/时间线视图
|
||||
|
||||
13. 📤 数据导出功能
|
||||
- 导出任务列表为 Excel/CSV
|
||||
|
||||
**LOW**
|
||||
14. 🔧 批量操作 API
|
||||
- 任务批量更新状态/优先级/删除接口
|
||||
|
||||
---
|
||||
|
||||
## 🎯 里程碑
|
||||
|
||||
| 里程碑 | 目标日期 | 状态 | 说明 |
|
||||
|--------|----------|------|------|
|
||||
| Phase 1: 核心平台层完成 | 2026-06-15 | ✅ 完成 | Clean Architecture + 核心业务对象 |
|
||||
| Phase 2: 项目管理模块落地 | 2026-06-16 | ✅ 完成 | 任务/里程碑/问题管理 + 前后端 |
|
||||
| Phase 3: 部署与备案完成 | 2026-06-30 | 🔄 进行中 | 生产部署 + HTTPS + 域名备案 |
|
||||
| Phase 4: SAAS 产品化完成 | 2026-07-15 | 📋 待开始 | 多租户 + 权限 + 订阅计费 |
|
||||
| Phase 5: AI 剪辑能力接入 | 2026-08-01 | 📋 待开始 | 视频分类 + 自动剪辑 + 配音 |
|
||||
|
||||
---
|
||||
|
||||
## 📐 技术架构
|
||||
|
||||
### 后端
|
||||
- **语言**:Python 3.12
|
||||
- **框架**:FastAPI
|
||||
- **数据库**:PostgreSQL(生产)+ SQLite(测试)
|
||||
- **缓存/队列**:Redis
|
||||
- **异步任务**:Celery
|
||||
- **ORM**:SQLAlchemy
|
||||
- **迁移**:Alembic
|
||||
- **存储**:MinIO(S3-compatible)
|
||||
|
||||
### 前端
|
||||
- **框架**:Next.js 14
|
||||
- **语言**:TypeScript
|
||||
- **UI 库**:React 18
|
||||
|
||||
### 架构模式
|
||||
- Clean Architecture
|
||||
- Ports & Adapters (Hexagonal)
|
||||
- Repository Pattern
|
||||
- Use Case Pattern
|
||||
|
||||
### 基础设施
|
||||
- **容器**:Docker + Docker Compose
|
||||
- **Web 服务器**:Nginx
|
||||
- **CI/CD**:Gitea Actions
|
||||
- **部署**:自建服务器(阿里云 ECS)
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ 关键目录
|
||||
|
||||
```
|
||||
xiaoxia-saas/
|
||||
├── packages/ # 共享业务逻辑包
|
||||
│ ├── domain/ # 核心实体与规则
|
||||
│ ├── application/ # 用例层
|
||||
│ ├── ports/ # 接口定义
|
||||
│ └── adapters/ # 接口实现
|
||||
│ ├── in_memory/ # 内存实现(测试)
|
||||
│ └── sqlalchemy_impl/ # PostgreSQL 实现
|
||||
├── apps/ # 应用层
|
||||
│ ├── api/ # FastAPI REST API
|
||||
│ ├── worker/ # Celery 异步任务
|
||||
│ └── web/ # Next.js 前端
|
||||
├── infra/ # 基础设施配置
|
||||
│ ├── docker/ # Docker Compose
|
||||
│ ├── scripts/ # 部署脚本
|
||||
│ ├── systemd/ # systemd 服务
|
||||
│ └── nginx/ # Nginx 配置(待添加)
|
||||
├── tests/ # 测试
|
||||
│ ├── integration/ # 集成测试
|
||||
│ └── e2e/ # 端到端测试(待添加)
|
||||
├── alembic/ # 数据库迁移
|
||||
├── scripts/ # 工具脚本
|
||||
│ ├── init_tracker_data.py # 推进器数据初始化(Python)
|
||||
│ └── init_tracker_data.ps1 # 推进器数据初始化(PowerShell)
|
||||
└── docs/ # 文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔑 关键决策记录
|
||||
|
||||
### 架构决策
|
||||
- ✅ 新 SaaS 与旧桌面版完全物理隔离
|
||||
- ✅ 旧桌面版仅作为业务参考,不再作为未来主线
|
||||
- ✅ 从第一天起就遵循 Clean Architecture
|
||||
- ✅ 持久化层提供双实现(便于测试)
|
||||
- ✅ 测试策略:集成测试优先,覆盖核心业务流程
|
||||
- ✅ 数据库迁移从第一天起就版本化管理
|
||||
|
||||
### 部署决策
|
||||
- ✅ CI/CD 基于 Gitea Actions + 自建 runner
|
||||
- ✅ 服务器优先开发/部署策略
|
||||
- ✅ 前端改为生产构建部署方案(非开发模式)
|
||||
- ✅ 临时用 8088/8089 端口绕过备案限制
|
||||
- ✅ 等备案通过后切换到 80/443 + HTTPS
|
||||
|
||||
### 工具链决策
|
||||
- ✅ 缺工具直接装,不找替代方案(避免出错)
|
||||
- ✅ Python 依赖装到 F 盘项目虚拟环境里
|
||||
- ✅ 旧项目推进器(纯前端 HTML)已废弃
|
||||
- ✅ 项目管理功能重新在 SAAS 里实现(后端 API + 前端 UI)
|
||||
|
||||
---
|
||||
|
||||
## 🔗 仓库信息
|
||||
|
||||
- **本地路径**:`F:\openclaw-saas`
|
||||
- **远程仓库**:`xiaoxia-server:/var/lib/xiaoxia-ci/xiaoxia-saas.git`
|
||||
- **服务器路径**:`/var/lib/xiaoxia-saas-staging/repo`
|
||||
- **分支**:`main`
|
||||
- **最新提交**:`c6f21d2 fix: remove duplicate api/v1 prefix in project-management routes`
|
||||
|
||||
---
|
||||
|
||||
## 📊 当前访问地址
|
||||
|
||||
### 临时地址(HTTP,绕过备案)
|
||||
- **前端**:http://47.98.113.167:8088
|
||||
- **API 文档**:http://47.98.113.167:8089/docs
|
||||
- **API 端点**:http://47.98.113.167:8089/api/v1/
|
||||
|
||||
### 正式域名(备案通过后)
|
||||
- **前端**:https://saas.xiaoxiajianji.com
|
||||
- **API**:https://saas-api.xiaoxiajianji.com
|
||||
|
||||
---
|
||||
|
||||
## 🐛 已知问题
|
||||
|
||||
### 1. 推进器 API 路由 404(高优先级)
|
||||
|
||||
**症状**:
|
||||
- 访问 `http://localhost:8000/api/v1/project-management/tasks` 返回 404
|
||||
- OpenAPI 文档显示路由为 `/api/v1/api/v1/project-management/tasks`(重复前缀)
|
||||
|
||||
**根因**:
|
||||
- `project_management.py` 里的 router 有 prefix `/api/v1/project-management`
|
||||
- 主应用 `main.py` 又把 `api_router` 挂载到 `/api/v1`
|
||||
- 导致前缀重复:`/api/v1` + `/api/v1/project-management`
|
||||
|
||||
**修复**:
|
||||
- 已修改 `project_management.py` 的 prefix 为 `/project-management`
|
||||
- 代码已提交:`c6f21d2`
|
||||
- 服务器仓库已拉取最新代码
|
||||
- **问题**:Docker 构建缓存顽固,容器内还是旧代码
|
||||
|
||||
**下一步**:
|
||||
- 方案 A:直接进入容器修改文件测试
|
||||
- 方案 B:完全清理 Docker 镜像层缓存再重建
|
||||
- 方案 C:临时跳过,先完成其他任务
|
||||
|
||||
---
|
||||
|
||||
## 📝 开发规则
|
||||
|
||||
### Git 工作流
|
||||
- ✅ 新功能开发在 `main` 分支(单人项目)
|
||||
- ✅ 每个功能完成后及时提交
|
||||
- ✅ 提交信息格式:`feat/fix/docs/refactor: 简短描述`
|
||||
- ✅ 推送前确保本地测试通过
|
||||
|
||||
### 测试策略
|
||||
- ✅ 集成测试优先(覆盖业务流程)
|
||||
- ✅ 每个 Use Case 至少 1 个测试
|
||||
- ✅ 新功能必须有测试
|
||||
- ✅ 修复 bug 先写测试重现
|
||||
|
||||
### 部署流程
|
||||
1. 本地开发 + 测试
|
||||
2. 提交到 Git
|
||||
3. 推送到服务器
|
||||
4. 服务器自动触发 CI/CD(或手动)
|
||||
5. Docker 重新构建
|
||||
6. 容器重启
|
||||
|
||||
---
|
||||
|
||||
## 🔐 敏感信息(不要泄露)
|
||||
|
||||
- **服务器 IP**:47.98.113.167
|
||||
- **SSH 别名**:xiaoxia-server
|
||||
- **数据库密码**:(存储在 `.env` 文件,不提交到 Git)
|
||||
- **MinIO 密钥**:(存储在 `.env` 文件)
|
||||
|
||||
---
|
||||
|
||||
## 🎓 技术债务
|
||||
|
||||
1. **In-Memory 持久化**:当前 API 用的还是 In-Memory,需切换到 PostgreSQL
|
||||
2. **认证缺失**:当前无认证,所有接口公开
|
||||
3. **错误处理**:部分接口错误处理不完善
|
||||
4. **日志**:缺少结构化日志
|
||||
5. **监控**:缺少性能监控和告警
|
||||
6. **备份**:缺少数据库备份策略
|
||||
|
||||
---
|
||||
|
||||
## 📚 参考文档
|
||||
|
||||
- **项目总览**:`README.md`
|
||||
- **当前状态**:`STATUS.md`(简化版)
|
||||
- **部署指南**:`infra/docker/SERVER-DEPLOY.md`
|
||||
- **CI/CD 说明**:`docs/CI-CD.md`
|
||||
|
||||
---
|
||||
|
||||
**以后每次新会话,先读这个文件快速恢复上下文。**
|
||||
@@ -1,332 +1,279 @@
|
||||
# Xiaoxia SaaS - AI 视频自动化剪辑系统
|
||||
# 小虾 SaaS - 自动化剪辑 SaaS 平台
|
||||
|
||||
新一代 SaaS 版小虾自动化剪辑系统,采用 Clean Architecture 重新设计与实现。
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://fastapi.tiangolo.com)
|
||||
[](https://www.postgresql.org/)
|
||||
|
||||
## 项目状态
|
||||
|
||||
**当前阶段**: 核心业务骨架已完成(Phase 1)
|
||||
|
||||
- ✅ Clean Architecture 架构就绪
|
||||
- ✅ 核心业务对象(User, Workspace, Project, AssetLibrary, Asset, IngestJob, ClassificationJob)
|
||||
- ✅ 完整上传→入库→Asset 创建链路
|
||||
- ✅ 完整分类任务链路
|
||||
- ✅ 双持久化实现(In-Memory + PostgreSQL)
|
||||
- ✅ Alembic 数据库迁移
|
||||
- ✅ 8 个集成测试全绿
|
||||
- ✅ Docker Compose 开发环境
|
||||
|
||||
## 技术栈
|
||||
|
||||
**Backend**
|
||||
- Python 3.12
|
||||
- FastAPI - REST API
|
||||
- Pydantic - 数据验证
|
||||
- SQLAlchemy - ORM
|
||||
- Alembic - 数据库迁移
|
||||
|
||||
**Worker**
|
||||
- Celery - 异步任务队列
|
||||
- Redis - 消息队列
|
||||
|
||||
**Database**
|
||||
- PostgreSQL - 生产数据库
|
||||
- SQLite - 测试环境
|
||||
|
||||
**Frontend** (占位)
|
||||
- Next.js 14
|
||||
- TypeScript
|
||||
- React 18
|
||||
|
||||
**Architecture**
|
||||
- Clean Architecture
|
||||
- Ports & Adapters (Hexagonal)
|
||||
- Repository Pattern
|
||||
- Use Case Pattern
|
||||
|
||||
**Testing**
|
||||
- pytest
|
||||
- 集成测试优先策略
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
xiaoxia-saas/
|
||||
├── packages/ # 共享业务逻辑包
|
||||
│ ├── domain/ # 核心业务实体与规则
|
||||
│ ├── application/ # 用例层
|
||||
│ ├── ports/ # 接口定义
|
||||
│ └── adapters/ # 接口实现
|
||||
│ ├── in_memory/ # 内存实现(测试用)
|
||||
│ └── sqlalchemy_impl/ # PostgreSQL 实现
|
||||
├── apps/ # 应用层
|
||||
│ ├── api/ # FastAPI REST API
|
||||
│ ├── worker/ # Celery 异步任务
|
||||
│ └── web/ # Next.js 前端(占位)
|
||||
├── infra/ # 基础设施配置
|
||||
│ ├── docker/ # Docker 配置
|
||||
│ └── nginx/ # Nginx 配置
|
||||
├── tests/ # 测试
|
||||
│ ├── integration/ # 集成测试
|
||||
│ └── e2e/ # 端到端测试(占位)
|
||||
├── alembic/ # 数据库迁移
|
||||
└── docs/ # 文档
|
||||
```
|
||||
|
||||
## 核心业务对象
|
||||
|
||||
### Domain Entities
|
||||
|
||||
**User** - 用户
|
||||
- 基本信息:id, email, display_name
|
||||
|
||||
**Workspace** - 工作空间
|
||||
- 用户的顶级组织单元
|
||||
- 拥有者:owner_user_id
|
||||
|
||||
**Project** - 项目
|
||||
- 属于某个 Workspace
|
||||
- 包含多个 AssetLibrary
|
||||
|
||||
**AssetLibrary** - 素材库
|
||||
- 类型:VIDEO(视频)/ VOICE(音频)
|
||||
- 属于某个 Project
|
||||
|
||||
**Asset** - 素材
|
||||
- 单个素材文件
|
||||
- 属于某个 AssetLibrary
|
||||
- 包含:storage_key, mime_type, metadata
|
||||
|
||||
**IngestJob** - 入库任务
|
||||
- 状态:PENDING → PROCESSING → COMPLETED/FAILED
|
||||
- 负责:文件上传后的元数据提取、Asset 创建
|
||||
- 结果:result_asset_id
|
||||
|
||||
**ClassificationJob** - 分类任务
|
||||
- 状态:PENDING → PROCESSING → COMPLETED/FAILED
|
||||
- 负责:Asset 的自动分类
|
||||
- 分类:scenic(风景), product(产品), person(人物), animal(动物), food(美食), tech(科技), sport(运动), music(音乐), other(其他)
|
||||
- 结果:classification + confidence
|
||||
|
||||
## 已完成功能
|
||||
|
||||
### API 接口(6 组)
|
||||
|
||||
**健康检查**
|
||||
- `GET /api/health` - 健康检查
|
||||
|
||||
**项目管理**
|
||||
- `GET /api/projects?workspace_id=...` - 项目列表
|
||||
- `POST /api/projects` - 创建项目
|
||||
|
||||
**素材库管理**
|
||||
- `GET /api/asset-libraries?project_id=...` - 素材库列表
|
||||
- `POST /api/asset-libraries` - 创建素材库
|
||||
|
||||
**素材管理**
|
||||
- `GET /api/assets?library_id=...` - 素材列表
|
||||
- `POST /api/assets` - 创建素材
|
||||
|
||||
**任务管理**
|
||||
- `POST /api/ingest-jobs` - 提交入库任务
|
||||
|
||||
**文件上传**
|
||||
- `POST /api/upload` - 上传素材文件
|
||||
|
||||
### Worker 任务(3 个)
|
||||
|
||||
**健康检查**
|
||||
- `worker.healthcheck` - Worker 健康检查
|
||||
|
||||
**入库任务**
|
||||
- `worker.ingest_asset` - 素材入库处理
|
||||
- 元数据提取(当前 mock,真实场景用 ffprobe)
|
||||
- Asset 创建
|
||||
- IngestJob 状态更新
|
||||
|
||||
**分类任务**
|
||||
- `worker.classify_asset` - 素材分类处理
|
||||
- 自动分类(当前 mock,真实场景用 ML 模型或 vision API)
|
||||
- ClassificationJob 状态更新
|
||||
|
||||
### 完整业务流程
|
||||
|
||||
**上传→入库→Asset 创建**
|
||||
1. 用户通过 `POST /api/upload` 上传文件
|
||||
2. API 生成 storage_key,创建 IngestJob(状态 PENDING)
|
||||
3. Celery 任务 `ingest_asset` 被入队
|
||||
4. Worker 处理:
|
||||
- 更新状态为 PROCESSING
|
||||
- 提取元数据
|
||||
- 创建 Asset 实体
|
||||
- 更新 IngestJob 状态为 COMPLETED,记录 result_asset_id
|
||||
5. 异常时更新状态为 FAILED,记录 error_message
|
||||
|
||||
**分类→结果**
|
||||
1. 创建 ClassificationJob(状态 PENDING)
|
||||
2. Celery 任务 `classify_asset` 被入队
|
||||
3. Worker 处理:
|
||||
- 更新状态为 PROCESSING
|
||||
- 运行分类模型
|
||||
- 更新 ClassificationJob 状态为 COMPLETED,记录 classification + confidence
|
||||
4. 异常时更新状态为 FAILED,记录 error_message
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 环境准备
|
||||
|
||||
```bash
|
||||
# 1. 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 2. 启动开发环境(Docker Compose)
|
||||
cd infra/docker
|
||||
docker-compose up -d
|
||||
|
||||
# 3. 运行数据库迁移
|
||||
alembic upgrade head
|
||||
|
||||
# 4. 启动 API(开发模式)
|
||||
cd apps/api
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# 5. 启动 Worker(开发模式)
|
||||
cd apps/worker
|
||||
celery -A worker_app.celery_app.celery_app worker --loglevel=info
|
||||
```
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
# 运行所有集成测试
|
||||
pytest tests/integration/ -v
|
||||
|
||||
# 运行指定测试
|
||||
pytest tests/integration/test_ingest_pipeline.py -v
|
||||
|
||||
# 运行所有测试(包含覆盖率)
|
||||
pytest --cov=packages --cov=apps --cov-report=html
|
||||
```
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
```bash
|
||||
# 创建新迁移
|
||||
alembic revision -m "description"
|
||||
|
||||
# 应用迁移
|
||||
alembic upgrade head
|
||||
|
||||
# 回滚迁移
|
||||
alembic downgrade -1
|
||||
|
||||
# 查看当前版本
|
||||
alembic current
|
||||
|
||||
# 查看迁移历史
|
||||
alembic history
|
||||
```
|
||||
|
||||
## 下一步计划
|
||||
|
||||
### Phase 2 - 基础设施完善(进行中)
|
||||
- [x] CI/CD 流水线(Gitea Actions)
|
||||
- [ ] 真实文件存储(MinIO / S3)
|
||||
- [ ] 生产环境配置(环境变量、密钥管理)
|
||||
- [ ] 监控与日志(Prometheus + Grafana)
|
||||
- [ ] API 文档(Swagger / ReDoc)
|
||||
|
||||
### Phase 3 - 核心业务扩展
|
||||
- [ ] 用户认证与授权(JWT)
|
||||
- [ ] Workspace 多用户协作
|
||||
- [ ] 视频剪辑任务(ClipJob)
|
||||
- [ ] 音频处理任务(AudioProcessJob)
|
||||
- [ ] 任务队列管理与监控
|
||||
- [ ] Webhook 通知
|
||||
|
||||
### Phase 4 - 前端开发
|
||||
- [ ] 用户登录/注册页面
|
||||
- [ ] 工作空间管理
|
||||
- [ ] 项目管理
|
||||
- [ ] 素材库管理
|
||||
- [ ] 素材上传与预览
|
||||
- [ ] 任务状态监控
|
||||
|
||||
### Phase 5 - 高级功能
|
||||
- [ ] 真实 ML 模型集成(分类、识别)
|
||||
- [ ] 批量处理
|
||||
- [ ] 定时任务
|
||||
- [ ] 数据分析与报表
|
||||
- [ ] API 限流与配额
|
||||
|
||||
## 架构决策记录
|
||||
|
||||
### ADR-001: Clean Architecture
|
||||
**日期**: 2026-06-15
|
||||
**状态**: 已采纳
|
||||
**决策**: 采用 Clean Architecture 重新设计系统
|
||||
**原因**:
|
||||
- 旧 desktop 系统耦合严重,难以测试和维护
|
||||
- 新 SaaS 需要长期演进,架构需要可扩展
|
||||
- Clean Architecture 提供清晰的依赖方向和边界
|
||||
|
||||
### ADR-002: 双持久化实现
|
||||
**日期**: 2026-06-15
|
||||
**状态**: 已采纳
|
||||
**决策**: 同时提供 In-Memory 和 SQLAlchemy 两种 Repository 实现
|
||||
**原因**:
|
||||
- In-Memory 实现用于测试,快速且无外部依赖
|
||||
- SQLAlchemy 实现用于生产,真实数据库持久化
|
||||
- Repository Pattern 使得实现可随时切换
|
||||
|
||||
### ADR-003: 集成测试优先
|
||||
**日期**: 2026-06-15
|
||||
**状态**: 已采纳
|
||||
**决策**: 集成测试优先于单元测试
|
||||
**原因**:
|
||||
- 核心业务流程需要端到端验证
|
||||
- In-Memory 实现使得集成测试成本低
|
||||
- 单元测试在架构稳定后逐步补充
|
||||
|
||||
## Commits 历史
|
||||
|
||||
1. `b5a62ee` - feat: initial SaaS scaffold
|
||||
2. `43fd071` - feat: implement ingest asset worker task
|
||||
3. `d550416` - feat: add upload asset endpoint
|
||||
4. `e4e2595` - feat: add PostgreSQL persistence layer
|
||||
5. `b43cdca` - feat: add Alembic database migrations
|
||||
6. `a8177c1` - feat: add asset classification pipeline
|
||||
|
||||
## 贡献指南
|
||||
|
||||
### 代码风格
|
||||
- 遵循 PEP 8
|
||||
- 使用 Black 格式化代码
|
||||
- 使用 type hints
|
||||
- 中文注释与文档
|
||||
|
||||
### Commit 规范
|
||||
- feat: 新功能
|
||||
- fix: 修复
|
||||
- docs: 文档
|
||||
- test: 测试
|
||||
- refactor: 重构
|
||||
- chore: 构建/工具
|
||||
|
||||
### Pull Request
|
||||
1. 基于 `main` 创建新分支
|
||||
2. 编写测试并确保通过
|
||||
3. 更新相关文档
|
||||
4. 提交 PR,描述改动内容
|
||||
|
||||
## 许可证
|
||||
|
||||
内部项目,未公开。
|
||||
|
||||
## 联系方式
|
||||
|
||||
技术问题:请联系小虾 AI 团队
|
||||
一个功能完整、生产就绪的多租户 SaaS 平台,专为自动化视频剪辑服务设计。
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-06-15
|
||||
**当前版本**: 0.1.0 (Phase 1 完成)
|
||||
## ✨ 核心特性
|
||||
|
||||
### 🎬 视频剪辑主链路(Phase 7 已完成)
|
||||
- 素材上传与管理
|
||||
- AI 智能分类
|
||||
- 自动化视频生成
|
||||
- 成片下载与管理
|
||||
|
||||
### 🔐 完整的认证系统
|
||||
- JWT 认证(access + refresh token)
|
||||
- 邮箱验证和密码重置
|
||||
- Session 管理
|
||||
- bcrypt 密码加密
|
||||
|
||||
### 🏢 多租户架构
|
||||
- 工作空间隔离
|
||||
- 团队成员管理
|
||||
- 基于角色的权限控制(Owner/Admin/Member/Viewer)
|
||||
- 邀请和审批流程
|
||||
|
||||
### 💸 订阅管理
|
||||
- 3 级订阅计划(Free/Pro/Enterprise)
|
||||
- 配额管理(项目数/存储空间)
|
||||
- 升级和取消订阅
|
||||
|
||||
### ⚡ 高性能
|
||||
- 数据库连接池(5-6x 性能提升)
|
||||
- 请求日志和监控
|
||||
- 慢查询检测
|
||||
- 健康检查(Kubernetes 就绪)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 方式 1: Docker(推荐)
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://github.com/your-org/xiaoxia-saas.git
|
||||
cd xiaoxia-saas
|
||||
|
||||
# 2. 启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
# 3. 访问 API 文档
|
||||
open http://localhost:8000/docs
|
||||
```
|
||||
|
||||
就这么简单!🎉
|
||||
|
||||
### 方式 2: 本地开发
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://github.com/your-org/xiaoxia-saas.git
|
||||
cd xiaoxia-saas
|
||||
|
||||
# 2. 创建虚拟环境
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
|
||||
# 3. 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 4. 启动开发服务器
|
||||
uvicorn apps.api.main:app --reload
|
||||
|
||||
# 5. 访问 API 文档
|
||||
open http://localhost:8000/docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 主要文档
|
||||
|
||||
### 快速入口
|
||||
- **[API 主线清单](docs/API-MAINLINE.md)** - 所有 API endpoints 总览
|
||||
- **[代码状态标注](docs/CODE-STATUS.md)** - 代码库导航指南
|
||||
- **[API 使用指南](docs/API-GUIDE.md)** - API 详细用法
|
||||
|
||||
### Phase 7 交付
|
||||
- **[Phase 7 进度](docs/PHASE7-PROGRESS.md)** - 核心视频剪辑业务已完成
|
||||
- **[Phase 7 设计](docs/PHASE7-DESIGN.md)** - 设计文档
|
||||
|
||||
### 基础设施
|
||||
- **[Docker 部署指南](docs/DOCKER-DEPLOYMENT.md)**
|
||||
- **[CI/CD 文档](docs/CI-CD.md)**
|
||||
- **[性能监控指南](docs/PERFORMANCE-MONITORING.md)**
|
||||
- **[健康检查指南](docs/HEALTH-CHECKS.md)**
|
||||
|
||||
### 开发规范
|
||||
- **[Git 工作流](docs/GIT-WORKFLOW.md)**
|
||||
- **[环境配置指南](docs/ENVIRONMENT-CONFIG.md)**
|
||||
- **[贡献指南](CONTRIBUTING.md)**
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 架构
|
||||
|
||||
```
|
||||
小虾 SaaS
|
||||
├── packages/ # 核心业务逻辑
|
||||
│ ├── domain/ # 领域模型
|
||||
│ ├── application/ # 用例
|
||||
│ ├── ports/ # 接口定义
|
||||
│ └── adapters/ # 适配器实现
|
||||
├── apps/ # 应用层
|
||||
│ ├── api/ # FastAPI 应用
|
||||
│ ├── web/ # React 前端
|
||||
│ └── worker/ # Celery worker
|
||||
├── migrations/ # 数据库迁移
|
||||
├── tests/ # 测试
|
||||
│ ├── unit/ # 单元测试
|
||||
│ └── integration/ # 集成测试
|
||||
└── docs/ # 文档
|
||||
```
|
||||
|
||||
**设计模式:**
|
||||
- Clean Architecture
|
||||
- 依赖注入
|
||||
- Repository 模式
|
||||
- Domain-Driven Design
|
||||
|
||||
---
|
||||
|
||||
## 🔑 核心 API 路径
|
||||
|
||||
### 视频剪辑主链路(Phase 7)
|
||||
|
||||
```
|
||||
上传素材
|
||||
↓
|
||||
POST /api/v1/upload
|
||||
|
||||
创建 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)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
pytest tests/ -v
|
||||
|
||||
# 运行单元测试
|
||||
pytest tests/unit -v
|
||||
|
||||
# 运行集成测试
|
||||
pytest tests/integration -v
|
||||
|
||||
# 生成覆盖率报告
|
||||
pytest --cov=packages --cov-report=html
|
||||
```
|
||||
|
||||
**测试统计:**
|
||||
- 单元测试: 17+ 个 ✅
|
||||
- 集成测试: 6+ 个 ✅
|
||||
- 测试覆盖率: 持续提升中
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
**后端:**
|
||||
- Python 3.12
|
||||
- FastAPI 0.115.0
|
||||
- PostgreSQL 16
|
||||
- Redis 7
|
||||
- Celery
|
||||
- MinIO
|
||||
|
||||
**前端:**
|
||||
- React 18
|
||||
- TypeScript
|
||||
- Vite
|
||||
- Ant Design
|
||||
|
||||
**测试:**
|
||||
- pytest
|
||||
- pytest-asyncio
|
||||
- pytest-cov
|
||||
|
||||
**部署:**
|
||||
- Docker
|
||||
- Docker Compose
|
||||
- Gitea Actions
|
||||
|
||||
---
|
||||
|
||||
## 📊 当前状态
|
||||
|
||||
| 模块 | 状态 |
|
||||
|------|------|
|
||||
| Phase 7(视频剪辑主链) | ✅ 已完成 |
|
||||
| CI/CD 稳定性 | ✅ 已修复 |
|
||||
| 认证系统 | ✅ 完整 |
|
||||
| 多租户架构 | ✅ 完整 |
|
||||
| 订阅系统 | ✅ 完整 |
|
||||
| 前端联调 | ✅ 完成 |
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎贡献!请查看 [贡献指南](CONTRIBUTING.md)
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'feat: Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 创建 Pull Request
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情
|
||||
|
||||
---
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
- **问题反馈:** GitHub Issues
|
||||
- **文档:** 参考 `docs/` 目录
|
||||
|
||||
---
|
||||
|
||||
## 🎉 致谢
|
||||
|
||||
感谢所有贡献者和使用者!
|
||||
|
||||
**开发团队:** 小虾 🦐
|
||||
|
||||
---
|
||||
|
||||
**⭐ 如果这个项目对你有帮助,请给一个 Star!**
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-06-19
|
||||
**当前版本**: Phase 7 完成
|
||||
ci test Fri Jun 26 06:59:37 PM CST 2026
|
||||
debug test Fri Jun 26 07:05:38 PM CST 2026
|
||||
docker daemon restart test
|
||||
19:08:39 - final test
|
||||
19:10:17 - new runner test
|
||||
|
||||
+9
-3
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
@@ -12,6 +12,10 @@ from packages.adapters.sqlalchemy_impl.models import Base
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if database_url:
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
@@ -66,7 +70,9 @@ def run_migrations_online() -> None:
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Current SQLAlchemy schema baseline.
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-06-21
|
||||
|
||||
This revision represents the current runtime schema defined by
|
||||
packages.adapters.sqlalchemy_impl.models. Existing staging databases should be
|
||||
stamped to this revision after compatibility verification; fresh databases can
|
||||
run this migration normally.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("email", sa.String(length=255), nullable=False),
|
||||
sa.Column("username", sa.String(length=100), nullable=True),
|
||||
sa.Column("display_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||
sa.Column("email_verified", sa.Boolean(), nullable=False),
|
||||
sa.Column("email_verification_token", sa.String(length=255), nullable=True),
|
||||
sa.Column("password_reset_token", sa.String(length=255), nullable=True),
|
||||
sa.Column("password_reset_expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_login_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_login_ip", sa.String(length=50), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
||||
op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"projects",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("name", sa.String(length=100), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_projects_workspace_id"), "projects", ["workspace_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"asset_libraries",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("asset_count", sa.Float(), nullable=False),
|
||||
sa.Column("total_size", sa.Float(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_asset_libraries_kind"), "asset_libraries", ["kind"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_asset_libraries_project_id"),
|
||||
"asset_libraries",
|
||||
["project_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_asset_libraries_workspace_id"),
|
||||
"asset_libraries",
|
||||
["workspace_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"assets",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("asset_library_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("name", sa.String(length=500), nullable=False),
|
||||
sa.Column("file_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("file_size", sa.Float(), nullable=False),
|
||||
sa.Column("file_url", sa.String(length=1000), nullable=False),
|
||||
sa.Column("thumbnail_url", sa.String(length=1000), nullable=True),
|
||||
sa.Column("duration", sa.Float(), nullable=True),
|
||||
sa.Column("width", sa.Float(), nullable=True),
|
||||
sa.Column("height", sa.Float(), nullable=True),
|
||||
sa.Column("fps", sa.Float(), nullable=True),
|
||||
sa.Column("codec", sa.String(length=50), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("classification_status", sa.String(length=20), nullable=False),
|
||||
sa.Column("classification_result", sa.Text(), nullable=True),
|
||||
sa.Column("quality_score", sa.Float(), nullable=True),
|
||||
sa.Column("uploaded_by_user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_assets_asset_library_id"), "assets", ["asset_library_id"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_assets_classification_status"),
|
||||
"assets",
|
||||
["classification_status"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(op.f("ix_assets_created_at"), "assets", ["created_at"], unique=False)
|
||||
op.create_index(op.f("ix_assets_file_type"), "assets", ["file_type"], unique=False)
|
||||
op.create_index(op.f("ix_assets_project_id"), "assets", ["project_id"], unique=False)
|
||||
op.create_index(op.f("ix_assets_status"), "assets", ["status"], unique=False)
|
||||
op.create_index(op.f("ix_assets_workspace_id"), "assets", ["workspace_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"ingest_jobs",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("library_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("storage_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("error_message", sa.Text(), nullable=False),
|
||||
sa.Column("result_asset_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_ingest_jobs_library_id"), "ingest_jobs", ["library_id"], unique=False)
|
||||
op.create_index(op.f("ix_ingest_jobs_project_id"), "ingest_jobs", ["project_id"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_ingest_jobs_workspace_id"),
|
||||
"ingest_jobs",
|
||||
["workspace_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"classification_jobs",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("asset_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("classification", sa.String(length=50), nullable=False),
|
||||
sa.Column("confidence", sa.Float(), nullable=False),
|
||||
sa.Column("error_message", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_classification_jobs_asset_id"),
|
||||
"classification_jobs",
|
||||
["asset_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_classification_jobs_project_id"),
|
||||
"classification_jobs",
|
||||
["project_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_classification_jobs_workspace_id"),
|
||||
"classification_jobs",
|
||||
["workspace_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"generation_tasks",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("strategy_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("asset_library_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("voice_library_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("progress", sa.Float(), nullable=False),
|
||||
sa.Column("result_count", sa.Float(), nullable=False),
|
||||
sa.Column("error_message", sa.Text(), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_by_user_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_generation_tasks_asset_library_id"),
|
||||
"generation_tasks",
|
||||
["asset_library_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_generation_tasks_project_id"),
|
||||
"generation_tasks",
|
||||
["project_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(op.f("ix_generation_tasks_status"), "generation_tasks", ["status"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_generation_tasks_workspace_id"),
|
||||
"generation_tasks",
|
||||
["workspace_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"generated_videos",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("generation_task_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("file_url", sa.String(length=1000), nullable=False),
|
||||
sa.Column("file_size", sa.Float(), nullable=False),
|
||||
sa.Column("duration", sa.Float(), nullable=False),
|
||||
sa.Column("thumbnail_url", sa.String(length=1000), nullable=True),
|
||||
sa.Column("width", sa.Float(), nullable=False),
|
||||
sa.Column("height", sa.Float(), nullable=False),
|
||||
sa.Column("fps", sa.Float(), nullable=False),
|
||||
sa.Column("generated_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_generated_videos_generation_task_id"),
|
||||
"generated_videos",
|
||||
["generation_task_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_generated_videos_project_id"),
|
||||
"generated_videos",
|
||||
["project_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_generated_videos_workspace_id"),
|
||||
"generated_videos",
|
||||
["workspace_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"tasks",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("name", sa.String(length=200), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("priority", sa.String(length=20), nullable=False),
|
||||
sa.Column("parent_task_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("assignee_user_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("progress", sa.Float(), nullable=False),
|
||||
sa.Column("planned_start_date", sa.DateTime(), nullable=True),
|
||||
sa.Column("planned_end_date", sa.DateTime(), nullable=True),
|
||||
sa.Column("actual_start_date", sa.DateTime(), nullable=True),
|
||||
sa.Column("actual_end_date", sa.DateTime(), nullable=True),
|
||||
sa.Column("tags_json", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_tasks_parent_task_id"), "tasks", ["parent_task_id"], unique=False)
|
||||
op.create_index(op.f("ix_tasks_project_id"), "tasks", ["project_id"], unique=False)
|
||||
op.create_index(op.f("ix_tasks_status"), "tasks", ["status"], unique=False)
|
||||
op.create_index(op.f("ix_tasks_workspace_id"), "tasks", ["workspace_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"milestones",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("name", sa.String(length=200), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("target_date", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed", sa.Boolean(), nullable=False),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_milestones_project_id"), "milestones", ["project_id"], unique=False)
|
||||
op.create_index(op.f("ix_milestones_workspace_id"), "milestones", ["workspace_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"task_issues",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("task_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("title", sa.String(length=200), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("resolved", sa.Boolean(), nullable=False),
|
||||
sa.Column("resolved_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_by_user_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_task_issues_project_id"), "task_issues", ["project_id"], unique=False)
|
||||
op.create_index(op.f("ix_task_issues_task_id"), "task_issues", ["task_id"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_task_issues_workspace_id"),
|
||||
"task_issues",
|
||||
["workspace_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("task_issues")
|
||||
op.drop_table("milestones")
|
||||
op.drop_table("tasks")
|
||||
op.drop_table("generated_videos")
|
||||
op.drop_table("generation_tasks")
|
||||
op.drop_table("classification_jobs")
|
||||
op.drop_table("ingest_jobs")
|
||||
op.drop_table("assets")
|
||||
op.drop_table("asset_libraries")
|
||||
op.drop_table("projects")
|
||||
op.drop_table("users")
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Initial schema
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-06-15
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '001'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create projects table
|
||||
op.create_table(
|
||||
'projects',
|
||||
sa.Column('id', sa.String(32), nullable=False),
|
||||
sa.Column('workspace_id', sa.String(32), nullable=False),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False, server_default=''),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_projects_workspace_id'), 'projects', ['workspace_id'], unique=False)
|
||||
|
||||
# Create asset_libraries table
|
||||
op.create_table(
|
||||
'asset_libraries',
|
||||
sa.Column('id', sa.String(32), nullable=False),
|
||||
sa.Column('workspace_id', sa.String(32), nullable=False),
|
||||
sa.Column('project_id', sa.String(32), nullable=False),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('kind', sa.String(20), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_asset_libraries_workspace_id'), 'asset_libraries', ['workspace_id'], unique=False)
|
||||
op.create_index(op.f('ix_asset_libraries_project_id'), 'asset_libraries', ['project_id'], unique=False)
|
||||
|
||||
# Create assets table
|
||||
op.create_table(
|
||||
'assets',
|
||||
sa.Column('id', sa.String(32), nullable=False),
|
||||
sa.Column('workspace_id', sa.String(32), nullable=False),
|
||||
sa.Column('project_id', sa.String(32), nullable=False),
|
||||
sa.Column('library_id', sa.String(32), nullable=False),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('storage_key', sa.String(255), nullable=False),
|
||||
sa.Column('mime_type', sa.String(100), nullable=False),
|
||||
sa.Column('metadata_json', sa.Text(), nullable=False, server_default='{}'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_assets_workspace_id'), 'assets', ['workspace_id'], unique=False)
|
||||
op.create_index(op.f('ix_assets_project_id'), 'assets', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_assets_library_id'), 'assets', ['library_id'], unique=False)
|
||||
|
||||
# Create ingest_jobs table
|
||||
op.create_table(
|
||||
'ingest_jobs',
|
||||
sa.Column('id', sa.String(32), nullable=False),
|
||||
sa.Column('workspace_id', sa.String(32), nullable=False),
|
||||
sa.Column('project_id', sa.String(32), nullable=False),
|
||||
sa.Column('library_id', sa.String(32), nullable=False),
|
||||
sa.Column('storage_key', sa.String(255), nullable=False),
|
||||
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
|
||||
sa.Column('error_message', sa.Text(), nullable=False, server_default=''),
|
||||
sa.Column('result_asset_id', sa.String(32), nullable=False, server_default=''),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_ingest_jobs_workspace_id'), 'ingest_jobs', ['workspace_id'], unique=False)
|
||||
op.create_index(op.f('ix_ingest_jobs_project_id'), 'ingest_jobs', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_ingest_jobs_library_id'), 'ingest_jobs', ['library_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('ingest_jobs')
|
||||
op.drop_table('assets')
|
||||
op.drop_table('asset_libraries')
|
||||
op.drop_table('projects')
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Add workspace core tables.
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-06-21
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "002"
|
||||
down_revision: Union[str, None] = "001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"workspaces",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("name", sa.String(length=100), nullable=False),
|
||||
sa.Column("owner_user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("subscription_plan", sa.String(length=20), nullable=False),
|
||||
sa.Column("subscription_status", sa.String(length=20), nullable=False),
|
||||
sa.Column("subscription_expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("max_projects", sa.Float(), nullable=False),
|
||||
sa.Column("max_storage_gb", sa.Float(), nullable=False),
|
||||
sa.Column("used_storage_gb", sa.Float(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_workspaces_owner_user_id"), "workspaces", ["owner_user_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"workspace_members",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("role", sa.String(length=20), nullable=False),
|
||||
sa.Column("invited_by", sa.String(length=36), nullable=True),
|
||||
sa.Column("joined_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("workspace_id", "user_id", name="uq_workspace_members_workspace_user"),
|
||||
)
|
||||
op.create_index(op.f("ix_workspace_members_user_id"), "workspace_members", ["user_id"], unique=False)
|
||||
op.create_index(op.f("ix_workspace_members_workspace_id"), "workspace_members", ["workspace_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"workspace_invitations",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("inviter_user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("invitee_email", sa.String(length=255), nullable=False),
|
||||
sa.Column("role", sa.String(length=20), nullable=False),
|
||||
sa.Column("invitation_token", sa.String(length=255), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("accepted_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workspace_invitations_invitation_token"),
|
||||
"workspace_invitations",
|
||||
["invitation_token"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workspace_invitations_invitee_email"), "workspace_invitations", ["invitee_email"], unique=False
|
||||
)
|
||||
op.create_index(op.f("ix_workspace_invitations_status"), "workspace_invitations", ["status"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_workspace_invitations_workspace_id"), "workspace_invitations", ["workspace_id"], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_workspace_invitations_workspace_id"), table_name="workspace_invitations")
|
||||
op.drop_index(op.f("ix_workspace_invitations_status"), table_name="workspace_invitations")
|
||||
op.drop_index(op.f("ix_workspace_invitations_invitee_email"), table_name="workspace_invitations")
|
||||
op.drop_index(op.f("ix_workspace_invitations_invitation_token"), table_name="workspace_invitations")
|
||||
op.drop_table("workspace_invitations")
|
||||
op.drop_index(op.f("ix_workspace_members_workspace_id"), table_name="workspace_members")
|
||||
op.drop_index(op.f("ix_workspace_members_user_id"), table_name="workspace_members")
|
||||
op.drop_table("workspace_members")
|
||||
op.drop_index(op.f("ix_workspaces_owner_user_id"), table_name="workspaces")
|
||||
op.drop_table("workspaces")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Add project titles.
|
||||
|
||||
Revision ID: 003
|
||||
Revises: 002
|
||||
Create Date: 2026-06-24
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "003"
|
||||
down_revision: Union[str, None] = "002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"project_titles",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("project_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("text", sa.String(length=200), nullable=False),
|
||||
sa.Column("category", sa.String(length=50), nullable=False, server_default="default"),
|
||||
sa.Column("usage_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("created_by_user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_project_titles_project_id"), "project_titles", ["project_id"], unique=False)
|
||||
op.create_index(op.f("ix_project_titles_workspace_id"), "project_titles", ["workspace_id"], unique=False)
|
||||
op.create_index(op.f("ix_project_titles_category"), "project_titles", ["category"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_project_titles_category"), table_name="project_titles")
|
||||
op.drop_index(op.f("ix_project_titles_workspace_id"), table_name="project_titles")
|
||||
op.drop_index(op.f("ix_project_titles_project_id"), table_name="project_titles")
|
||||
op.drop_table("project_titles")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Add project title favorite flag.
|
||||
|
||||
Revision ID: 004
|
||||
Revises: 003
|
||||
Create Date: 2026-06-24
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "004"
|
||||
down_revision: Union[str, None] = "003"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("project_titles", sa.Column("favorite", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
op.create_index(op.f("ix_project_titles_favorite"), "project_titles", ["favorite"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_project_titles_favorite"), table_name="project_titles")
|
||||
op.drop_column("project_titles", "favorite")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Add generated video management fields.
|
||||
|
||||
Revision ID: 005
|
||||
Revises: 004
|
||||
Create Date: 2026-06-24
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "005"
|
||||
down_revision: Union[str, None] = "004"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generated_videos", sa.Column("status", sa.String(length=20), nullable=False, server_default="completed")
|
||||
)
|
||||
op.add_column(
|
||||
"generated_videos",
|
||||
sa.Column("review_status", sa.String(length=20), nullable=False, server_default="pending_review"),
|
||||
)
|
||||
op.add_column("generated_videos", sa.Column("generation_params", sa.Text(), nullable=False, server_default="{}"))
|
||||
op.add_column("generated_videos", sa.Column("updated_at", sa.DateTime(), nullable=True))
|
||||
op.create_index(op.f("ix_generated_videos_status"), "generated_videos", ["status"], unique=False)
|
||||
op.create_index(op.f("ix_generated_videos_review_status"), "generated_videos", ["review_status"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_generated_videos_review_status"), table_name="generated_videos")
|
||||
op.drop_index(op.f("ix_generated_videos_status"), table_name="generated_videos")
|
||||
op.drop_column("generated_videos", "updated_at")
|
||||
op.drop_column("generated_videos", "generation_params")
|
||||
op.drop_column("generated_videos", "review_status")
|
||||
op.drop_column("generated_videos", "status")
|
||||
@@ -0,0 +1,59 @@
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "006"
|
||||
down_revision = "005"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"edit_templates",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("workspace_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("project_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("name", sa.String(120), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("target_duration", sa.Float(), nullable=False, server_default="30"),
|
||||
sa.Column("clip_count", sa.Integer(), nullable=False, server_default="3"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default=""),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_table(
|
||||
"edit_plans",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("workspace_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("project_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("template_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("asset_library_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("title_id", sa.String(32), nullable=False, server_default=""),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="draft", index=True),
|
||||
sa.Column("summary", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default=""),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_table(
|
||||
"edit_plan_clips",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("edit_plan_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("asset_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("start_time", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("duration", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("reason", sa.Text(), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column("generation_tasks", sa.Column("edit_plan_id", sa.String(32), nullable=False, server_default=""))
|
||||
op.create_index("ix_generation_tasks_edit_plan_id", "generation_tasks", ["edit_plan_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_generation_tasks_edit_plan_id", table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "edit_plan_id")
|
||||
op.drop_table("edit_plan_clips")
|
||||
op.drop_table("edit_plans")
|
||||
op.drop_table("edit_templates")
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Add video fingerprint and duplicate detection fields to generated_videos table.
|
||||
|
||||
Revision ID: 007
|
||||
Revises: 006
|
||||
Create Date: 2024-06-26
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "007"
|
||||
down_revision = "006"
|
||||
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")
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Remove workspace concept - Projects now directly under User
|
||||
|
||||
Revision ID: 007_remove_workspace_concept
|
||||
Revises: 006_add_edit_plan_tables
|
||||
Create Date: 2026-06-26
|
||||
|
||||
This migration:
|
||||
1. Moves subscription/quota fields from workspaces to users table
|
||||
2. Converts projects.workspace_id to projects.owner_user_id
|
||||
3. Adds shared_users JSON field to projects table
|
||||
4. Removes workspace_id from all tables that had it
|
||||
5. Drops workspace-related tables: workspaces, workspace_members, workspace_invitations
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
# revision identifiers
|
||||
revision = "007_remove_workspace_concept"
|
||||
down_revision = "006_add_edit_plan_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Step 1: Add subscription/quota fields to users table
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS subscription_plan VARCHAR(20) NOT NULL DEFAULT free
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(20) NOT NULL DEFAULT active
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS subscription_expires_at TIMESTAMP
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS max_projects FLOAT NOT NULL DEFAULT 3
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS max_storage_gb FLOAT NOT NULL DEFAULT 10
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS used_storage_gb FLOAT NOT NULL DEFAULT 0
|
||||
"""))
|
||||
|
||||
# Step 2: Copy subscription data from workspaces to users
|
||||
conn.execute(text("""
|
||||
UPDATE users SET
|
||||
subscription_plan = w.subscription_plan,
|
||||
subscription_status = w.subscription_status,
|
||||
subscription_expires_at = w.subscription_expires_at,
|
||||
max_projects = w.max_projects,
|
||||
max_storage_gb = w.max_storage_gb,
|
||||
used_storage_gb = w.used_storage_gb
|
||||
FROM workspaces w
|
||||
WHERE w.owner_user_id = users.id
|
||||
"""))
|
||||
|
||||
# Step 3: Add owner_user_id and shared_users to projects table
|
||||
conn.execute(text("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS owner_user_id VARCHAR(32)
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS shared_users JSON
|
||||
"""))
|
||||
|
||||
# Step 4: Migrate workspace_id to owner_user_id (from workspace_members where role=owner)
|
||||
conn.execute(text("""
|
||||
UPDATE projects SET
|
||||
owner_user_id = wm.user_id
|
||||
FROM workspace_members wm
|
||||
WHERE wm.workspace_id = projects.workspace_id
|
||||
AND wm.role = owner
|
||||
"""))
|
||||
|
||||
# Set shared_users to empty array for all projects
|
||||
conn.execute(text("""
|
||||
UPDATE projects SET shared_users = []::json
|
||||
WHERE shared_users IS NULL
|
||||
"""))
|
||||
|
||||
# Step 5: Remove workspace_id from all tables
|
||||
tables_with_workspace_id = [
|
||||
"asset_libraries",
|
||||
"assets",
|
||||
"classification_jobs",
|
||||
"edit_plans",
|
||||
"edit_templates",
|
||||
"generation_tasks",
|
||||
"generated_videos",
|
||||
"ingest_jobs",
|
||||
"milestones",
|
||||
"project_titles",
|
||||
"tasks",
|
||||
"task_issues",
|
||||
]
|
||||
|
||||
for table in tables_with_workspace_id:
|
||||
conn.execute(text(f"""
|
||||
ALTER TABLE {table} DROP COLUMN IF EXISTS workspace_id
|
||||
"""))
|
||||
|
||||
# Step 6: Drop workspace-related tables
|
||||
conn.execute(text("""
|
||||
DROP TABLE IF EXISTS workspace_invitations
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
DROP TABLE IF EXISTS workspace_members
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
DROP TABLE IF EXISTS workspaces
|
||||
"""))
|
||||
|
||||
# Step 7: Drop workspace_id from projects table
|
||||
conn.execute(text("""
|
||||
ALTER TABLE projects DROP COLUMN IF EXISTS workspace_id
|
||||
"""))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Add back workspace tables (simplified - in real scenario would need full recreation)
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
owner_user_id VARCHAR(36) NOT NULL,
|
||||
subscription_plan VARCHAR(20) NOT NULL DEFAULT free,
|
||||
subscription_status VARCHAR(20) NOT NULL DEFAULT active,
|
||||
subscription_expires_at TIMESTAMP,
|
||||
max_projects FLOAT NOT NULL DEFAULT 3,
|
||||
max_storage_gb FLOAT NOT NULL DEFAULT 10,
|
||||
used_storage_gb FLOAT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS workspace_members (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
workspace_id VARCHAR(36) NOT NULL,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
invited_by VARCHAR(36),
|
||||
joined_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(workspace_id, user_id)
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS workspace_invitations (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
workspace_id VARCHAR(36) NOT NULL,
|
||||
inviter_user_id VARCHAR(36) NOT NULL,
|
||||
invitee_email VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
invitation_token VARCHAR(255) NOT NULL UNIQUE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT pending,
|
||||
expires_at TIMESTAMP,
|
||||
accepted_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
# Add back workspace_id column to projects
|
||||
conn.execute(text("""
|
||||
ALTER TABLE projects ADD COLUMN workspace_id VARCHAR(32)
|
||||
"""))
|
||||
|
||||
# Add back workspace_id columns to other tables
|
||||
tables_with_workspace_id = [
|
||||
"asset_libraries",
|
||||
"assets",
|
||||
"classification_jobs",
|
||||
"edit_plans",
|
||||
"edit_templates",
|
||||
"generation_tasks",
|
||||
"generated_videos",
|
||||
"ingest_jobs",
|
||||
"milestones",
|
||||
"project_titles",
|
||||
"tasks",
|
||||
"task_issues",
|
||||
]
|
||||
|
||||
for table in tables_with_workspace_id:
|
||||
conn.execute(text(f"""
|
||||
ALTER TABLE {table} ADD COLUMN workspace_id VARCHAR(36)
|
||||
"""))
|
||||
|
||||
# Note: This downgrade is incomplete - projects.owner_user_id data would need to be
|
||||
# converted back to workspace_ids, which requires reconstructing workspace records.
|
||||
+67
-23
@@ -1,52 +1,96 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
|
||||
from app.api.routes.asset_libraries import router as asset_libraries_router
|
||||
from app.api.routes.assets import router as assets_router
|
||||
from app.api.routes.health import router as health_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.edit_plans import router as edit_plans_router
|
||||
from app.api.routes.generated_videos import router as generated_videos_router
|
||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||
from app.api.routes.health import router as health_check_router
|
||||
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||
from app.api.routes.project_management import router as project_management_router
|
||||
from app.api.routes.project_titles import router as project_titles_router
|
||||
from app.api.routes.projects import router as projects_router
|
||||
from app.api.routes.task_center import router as task_center_router
|
||||
from app.api.routes.upload import router as upload_router
|
||||
from app.api.routes.workspaces import router as workspaces_router
|
||||
from fastapi import APIRouter
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
health_router = APIRouter()
|
||||
health_router.include_router(health_check_router)
|
||||
|
||||
# Health check
|
||||
api_router.include_router(
|
||||
health_router,
|
||||
prefix="/health",
|
||||
tags=["健康检查"],
|
||||
auth_router,
|
||||
tags=["Auth"],
|
||||
)
|
||||
api_router.include_router(
|
||||
workspaces_router,
|
||||
tags=["Workspace"],
|
||||
)
|
||||
|
||||
# Projects
|
||||
api_router.include_router(
|
||||
projects_router,
|
||||
prefix="/projects",
|
||||
tags=["项目管理"],
|
||||
tags=["Project"],
|
||||
)
|
||||
api_router.include_router(
|
||||
project_titles_router,
|
||||
tags=["TitleLibrary"],
|
||||
)
|
||||
api_router.include_router(
|
||||
edit_plans_router,
|
||||
)
|
||||
api_router.include_router(
|
||||
task_center_router,
|
||||
tags=["TaskCenter"],
|
||||
)
|
||||
api_router.include_router(
|
||||
asset_diagnosis_router,
|
||||
tags=["AssetDiagnosis"],
|
||||
)
|
||||
|
||||
# Asset Libraries
|
||||
api_router.include_router(
|
||||
asset_libraries_router,
|
||||
prefix="/asset-libraries",
|
||||
tags=["资产库管理"],
|
||||
tags=["AssetLibrary"],
|
||||
)
|
||||
|
||||
# Assets
|
||||
api_router.include_router(
|
||||
assets_router,
|
||||
prefix="/assets",
|
||||
tags=["素材资产"],
|
||||
tags=["Asset"],
|
||||
)
|
||||
|
||||
# Ingest Jobs
|
||||
api_router.include_router(
|
||||
ingest_jobs_router,
|
||||
prefix="/ingest-jobs",
|
||||
tags=["导入任务"],
|
||||
tags=["IngestJob"],
|
||||
)
|
||||
api_router.include_router(
|
||||
classification_jobs_router,
|
||||
prefix="/classification-jobs",
|
||||
tags=["ClassificationJob"],
|
||||
)
|
||||
|
||||
# Upload
|
||||
api_router.include_router(
|
||||
upload_router,
|
||||
prefix="/upload",
|
||||
tags=["文件上传"],
|
||||
tags=["Upload"],
|
||||
)
|
||||
api_router.include_router(
|
||||
chunked_upload_router,
|
||||
prefix="/upload/chunk",
|
||||
tags=["ChunkedUpload"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generation_tasks_router,
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generated_videos_router,
|
||||
prefix="/generated-videos",
|
||||
tags=["GeneratedVideo"],
|
||||
)
|
||||
api_router.include_router(
|
||||
project_management_router,
|
||||
prefix="/project-management",
|
||||
tags=["ProjectManagement"],
|
||||
)
|
||||
|
||||
@@ -1 +1,17 @@
|
||||
"""Route modules."""
|
||||
"""API route package.
|
||||
|
||||
The canonical aggregated router lives in `app.api.router`. This package must not
|
||||
import route modules at package-import time, otherwise importing any sub-route can
|
||||
trigger circular imports and optional infrastructure dependencies.
|
||||
"""
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name in {"api_router", "health_router"}:
|
||||
from app.api.router import api_router, health_router
|
||||
|
||||
return {"api_router": api_router, "health_router": health_router}[name]
|
||||
raise AttributeError(name)
|
||||
|
||||
|
||||
__all__ = ["api_router", "health_router"]
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
from typing import Any
|
||||
|
||||
from app.api.routes.permissions import require_workspace_member
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_project_repository,
|
||||
get_workspace_member_repository,
|
||||
)
|
||||
from app.schemas.asset_diagnosis import AssetGapItem, AssetSmartViewItem, ProjectAssetDiagnosisResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.domain import Asset, AssetLibraryKind, AssetStatus
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _asset_kind(asset: Asset) -> str:
|
||||
if asset.mime_type.startswith("video"):
|
||||
return "video"
|
||||
if asset.mime_type.startswith("audio"):
|
||||
return "voice"
|
||||
if asset.mime_type.startswith("image"):
|
||||
return "image"
|
||||
return asset.mime_type.split("/", 1)[0]
|
||||
|
||||
|
||||
def _readiness_label(score: int) -> str:
|
||||
if score >= 80:
|
||||
return "素材充足"
|
||||
if score >= 60:
|
||||
return "基本可生成"
|
||||
if score >= 40:
|
||||
return "需要补素材"
|
||||
return "暂不建议生成"
|
||||
|
||||
|
||||
def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) -> ProjectAssetDiagnosisResponse:
|
||||
ready_assets = [asset for asset in assets if asset.status == AssetStatus.READY]
|
||||
video_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.VIDEO]
|
||||
image_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.IMAGE]
|
||||
voice_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.VOICE]
|
||||
problem_assets = [
|
||||
asset for asset in assets if asset.status in {AssetStatus.ERROR, AssetStatus.UPLOADING, AssetStatus.PROCESSING}
|
||||
]
|
||||
unclassified_assets = [
|
||||
asset for asset in ready_assets if asset.classification_status.value in {"pending", "failed"}
|
||||
]
|
||||
risky_assets = [
|
||||
asset
|
||||
for asset in ready_assets
|
||||
if (asset.quality_score is not None and asset.quality_score < 60)
|
||||
or asset.metadata.get("review_status") == "rejected"
|
||||
or asset.status == AssetStatus.ERROR
|
||||
]
|
||||
used_assets = [asset for asset in ready_assets if int(asset.metadata.get("generation_use_count") or 0) > 0]
|
||||
unused_assets = [asset for asset in ready_assets if int(asset.metadata.get("generation_use_count") or 0) == 0]
|
||||
pending_review_assets = [asset for asset in ready_assets if asset.metadata.get("review_status") == "pending_review"]
|
||||
total_duration = round(sum(float(asset.duration or 0) for asset in video_assets), 2)
|
||||
estimated_video_count = max(
|
||||
0, min(len(video_assets), int(total_duration // 5) if total_duration else len(video_assets))
|
||||
)
|
||||
|
||||
score = 20
|
||||
if video_assets:
|
||||
score += 30
|
||||
if len(video_assets) >= 3:
|
||||
score += 15
|
||||
if total_duration >= 15:
|
||||
score += 15
|
||||
if image_assets:
|
||||
score += 5
|
||||
if voice_assets:
|
||||
score += 5
|
||||
if not problem_assets:
|
||||
score += 10
|
||||
score = max(0, min(100, score - min(25, len(risky_assets) * 5)))
|
||||
|
||||
gaps: list[AssetGapItem] = []
|
||||
if not video_assets:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="missing_video",
|
||||
severity="critical",
|
||||
message="缺少可用于生成的视频素材",
|
||||
recommendation="至少上传 1 个已导入完成的视频素材;建议上传 3 个以上,生成效果更稳定。",
|
||||
)
|
||||
)
|
||||
elif len(video_assets) < 3:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="low_video_count",
|
||||
severity="warning",
|
||||
message="视频素材数量偏少",
|
||||
recommendation="建议补充到 3 个以上视频素材,方便生成更多候选成片。",
|
||||
)
|
||||
)
|
||||
if total_duration and total_duration < 15:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="short_video_duration",
|
||||
severity="warning",
|
||||
message="可用视频总时长偏短",
|
||||
recommendation="建议补充更多原始视频,至少达到 15 秒以上。",
|
||||
)
|
||||
)
|
||||
if not voice_assets:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="missing_voice",
|
||||
severity="info",
|
||||
message="暂未配置配音素材",
|
||||
recommendation="如果本项目需要口播/旁白,请上传配音素材;纯画面生成可暂时忽略。",
|
||||
)
|
||||
)
|
||||
if problem_assets:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="not_ready_assets",
|
||||
severity="warning",
|
||||
message=f"有 {len(problem_assets)} 个素材尚未 ready",
|
||||
recommendation="等待导入完成或删除失败素材后再生成。",
|
||||
)
|
||||
)
|
||||
if risky_assets:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="low_quality_assets",
|
||||
severity="warning",
|
||||
message=f"有 {len(risky_assets)} 个素材质量分偏低",
|
||||
recommendation="优先使用清晰、稳定、时长充足的视频素材。",
|
||||
)
|
||||
)
|
||||
|
||||
smart_views = [
|
||||
AssetSmartViewItem(
|
||||
key="recommended", label="推荐素材", count=len(video_assets), description="已导入完成、可参与生成的视频素材"
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="needs_attention",
|
||||
label="慎用素材",
|
||||
count=len(problem_assets) + len(risky_assets),
|
||||
description="导入未完成、失败或质量分偏低的素材",
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="high_risk", label="高风险素材", count=len(risky_assets), description="质量分偏低或复核拒绝的素材"
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="unclassified",
|
||||
label="未分类素材",
|
||||
count=len(unclassified_assets),
|
||||
description="尚未完成分类或分类失败的 ready 素材",
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="recent",
|
||||
label="最近上传",
|
||||
count=min(len(assets), 10),
|
||||
description="最近进入素材库的素材,可用于快速复核",
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="unused", label="未使用素材", count=len(unused_assets), description="尚未参与生成的 ready 素材"
|
||||
),
|
||||
AssetSmartViewItem(key="used", label="已使用素材", count=len(used_assets), description="已经参与过生成的素材"),
|
||||
AssetSmartViewItem(
|
||||
key="pending_review",
|
||||
label="待复核素材",
|
||||
count=len(pending_review_assets),
|
||||
description="生成后待人工复核的素材",
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="voice", label="配音素材", count=len(voice_assets), description="可用于后续配音/旁白工作流的素材"
|
||||
),
|
||||
]
|
||||
|
||||
return ProjectAssetDiagnosisResponse(
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
readiness_score=score,
|
||||
readiness_label=_readiness_label(score),
|
||||
total_assets=len(assets),
|
||||
ready_assets=len(ready_assets),
|
||||
video_assets=len(video_assets),
|
||||
image_assets=len(image_assets),
|
||||
voice_assets=len(voice_assets),
|
||||
total_duration_seconds=total_duration,
|
||||
estimated_video_count=estimated_video_count,
|
||||
used_assets=len(used_assets),
|
||||
unused_assets=len(unused_assets),
|
||||
pending_review_assets=len(pending_review_assets),
|
||||
smart_views=smart_views,
|
||||
gaps=gaps,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/asset-diagnosis", response_model=ProjectAssetDiagnosisResponse)
|
||||
def get_project_asset_diagnosis(
|
||||
project_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ProjectAssetDiagnosisResponse:
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
|
||||
|
||||
libraries = asset_library_repository.list_by_project(project_id)
|
||||
assets: list[Asset] = []
|
||||
for library in libraries:
|
||||
assets.extend(asset_repository.list_by_library(library.id))
|
||||
|
||||
return _build_diagnosis(project.workspace_id, project_id, assets)
|
||||
@@ -1,55 +1,80 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from typing import Any
|
||||
|
||||
from app.dependencies import get_asset_library_repository
|
||||
from app.schemas.asset_library import AssetLibraryResponse, CreateAssetLibraryRequest, ListAssetLibrariesResponse
|
||||
from packages.adapters.in_memory import InMemoryAssetLibraryRepository
|
||||
from packages.application import CreateAssetLibraryCommand, CreateAssetLibraryUseCase, ListAssetLibrariesUseCase
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_asset_library_repository, get_project_repository
|
||||
from app.schemas.asset_library import (
|
||||
AssetLibraryResponse,
|
||||
CreateAssetLibraryRequest,
|
||||
ListAssetLibrariesResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from packages.application import (
|
||||
CreateAssetLibraryCommand,
|
||||
CreateAssetLibraryUseCase,
|
||||
GetProjectUseCase,
|
||||
ListAssetLibrariesUseCase,
|
||||
)
|
||||
from packages.domain import AssetLibraryKind
|
||||
|
||||
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 _to_asset_library_response(item) -> AssetLibraryResponse:
|
||||
return AssetLibraryResponse(
|
||||
id=item.id,
|
||||
project_id=item.project_id,
|
||||
name=item.name,
|
||||
kind=item.kind.value,
|
||||
asset_count=item.asset_count,
|
||||
total_size=item.total_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ListAssetLibrariesResponse)
|
||||
def list_asset_libraries(
|
||||
project_id: str,
|
||||
kind: str | None = None,
|
||||
asset_library_repository: InMemoryAssetLibraryRepository = Depends(get_asset_library_repository),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ListAssetLibrariesResponse:
|
||||
project = GetProjectUseCase(project_repository).execute(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
if not project.can_access(authenticated_user.id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project")
|
||||
use_case = ListAssetLibrariesUseCase(asset_library_repository)
|
||||
parsed_kind = AssetLibraryKind(kind) if kind else None
|
||||
items = use_case.execute(project_id, kind=parsed_kind)
|
||||
return ListAssetLibrariesResponse(
|
||||
items=[
|
||||
AssetLibraryResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
name=item.name,
|
||||
kind=item.kind.value,
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
)
|
||||
items = use_case.execute(project_id)
|
||||
return ListAssetLibrariesResponse(items=[_to_asset_library_response(item) for item in items])
|
||||
|
||||
|
||||
@router.post("", response_model=AssetLibraryResponse)
|
||||
def create_asset_library(
|
||||
request: CreateAssetLibraryRequest,
|
||||
asset_library_repository: InMemoryAssetLibraryRepository = Depends(get_asset_library_repository),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AssetLibraryResponse:
|
||||
project = project_repository.find_by_id(request.project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
if not project.can_access(authenticated_user.id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project")
|
||||
use_case = CreateAssetLibraryUseCase(asset_library_repository)
|
||||
item = use_case.execute(
|
||||
CreateAssetLibraryCommand(
|
||||
workspace_id=request.workspace_id,
|
||||
project_id=request.project_id,
|
||||
name=request.name,
|
||||
kind=AssetLibraryKind(request.kind),
|
||||
)
|
||||
)
|
||||
return AssetLibraryResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
name=item.name,
|
||||
kind=item.kind.value,
|
||||
)
|
||||
return _to_asset_library_response(item)
|
||||
|
||||
@@ -1,61 +1,136 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from typing import Any
|
||||
|
||||
from app.dependencies import get_asset_repository
|
||||
from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse
|
||||
from packages.adapters.in_memory import InMemoryAssetRepository
|
||||
from packages.application import CreateAssetCommand, CreateAssetUseCase, ListAssetsUseCase
|
||||
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 import AssetResponse, CreateAssetRequest, ListAssetsResponse, UpdateAssetReviewRequest
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
CreateAssetCommand,
|
||||
CreateAssetUseCase,
|
||||
ListAssetsUseCase,
|
||||
)
|
||||
from packages.domain import AssetStatus, ClassificationStatus
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=ListAssetsResponse)
|
||||
def list_assets(
|
||||
library_id: str,
|
||||
asset_repository: InMemoryAssetRepository = Depends(get_asset_repository),
|
||||
) -> ListAssetsResponse:
|
||||
use_case = ListAssetsUseCase(asset_repository)
|
||||
items = use_case.execute(library_id)
|
||||
return ListAssetsResponse(
|
||||
items=[
|
||||
AssetResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
library_id=item.library_id,
|
||||
name=item.name,
|
||||
storage_key=item.storage_key,
|
||||
mime_type=item.mime_type,
|
||||
metadata=item.metadata,
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=AssetResponse)
|
||||
def create_asset(
|
||||
request: CreateAssetRequest,
|
||||
asset_repository: InMemoryAssetRepository = Depends(get_asset_repository),
|
||||
) -> AssetResponse:
|
||||
use_case = CreateAssetUseCase(asset_repository)
|
||||
item = use_case.execute(
|
||||
CreateAssetCommand(
|
||||
workspace_id=request.workspace_id,
|
||||
project_id=request.project_id,
|
||||
library_id=request.library_id,
|
||||
name=request.name,
|
||||
storage_key=request.storage_key,
|
||||
mime_type=request.mime_type,
|
||||
metadata=request.metadata,
|
||||
)
|
||||
)
|
||||
def _to_asset_response(item) -> AssetResponse:
|
||||
return AssetResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
library_id=item.library_id,
|
||||
name=item.name,
|
||||
storage_key=item.storage_key,
|
||||
mime_type=item.mime_type,
|
||||
metadata=item.metadata,
|
||||
file_size=item.file_size,
|
||||
thumbnail_url=item.thumbnail_url,
|
||||
duration=item.duration,
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
fps=item.fps,
|
||||
codec=item.codec,
|
||||
status=item.status.value,
|
||||
classification_status=item.classification_status.value,
|
||||
quality_score=item.quality_score,
|
||||
uploaded_by_user_id=item.uploaded_by_user_id,
|
||||
)
|
||||
|
||||
|
||||
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),
|
||||
) -> 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.id, project_repository)
|
||||
use_case = ListAssetsUseCase(asset_repository)
|
||||
items = use_case.execute(library_id)
|
||||
return ListAssetsResponse(items=[_to_asset_response(item) for item in items])
|
||||
|
||||
|
||||
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.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,
|
||||
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),
|
||||
) -> AssetResponse:
|
||||
project = project_repository.find_by_id(request.project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found")
|
||||
if not project.can_access(authenticated_user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
library = asset_library_repository.get(request.library_id)
|
||||
if library is None or library.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(
|
||||
project_id=request.project_id,
|
||||
library_id=request.library_id,
|
||||
name=request.name,
|
||||
storage_key=request.storage_key,
|
||||
mime_type=request.mime_type,
|
||||
metadata=request.metadata,
|
||||
file_size=request.file_size,
|
||||
thumbnail_url=request.thumbnail_url,
|
||||
duration=request.duration,
|
||||
width=request.width,
|
||||
height=request.height,
|
||||
fps=request.fps,
|
||||
codec=request.codec,
|
||||
status=AssetStatus(request.status),
|
||||
classification_status=ClassificationStatus(request.classification_status),
|
||||
quality_score=request.quality_score,
|
||||
uploaded_by_user_id=authenticated_user.id,
|
||||
)
|
||||
)
|
||||
return _to_asset_response(item)
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
Canonical authentication API routes.
|
||||
|
||||
The route layer is intentionally thin: repository construction lives in
|
||||
app.dependencies and authentication behavior lives in application use cases.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import settings
|
||||
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from packages.adapters.redis import NoopSessionStore
|
||||
from packages.adapters.smtp import NoopEmailService
|
||||
from packages.application.auth.login_use_case import LoginRequest as LoginUseCaseRequest
|
||||
from packages.application.auth.login_use_case import LoginUseCase
|
||||
from packages.application.auth.password_reset_use_case import RequestPasswordResetRequest as PasswordResetUseCaseRequest
|
||||
from packages.application.auth.password_reset_use_case import (
|
||||
RequestPasswordResetUseCase,
|
||||
ResetPasswordRequest,
|
||||
ResetPasswordUseCase,
|
||||
)
|
||||
from packages.application.auth.register_user_use_case import RegisterUserRequest as RegisterUseCaseRequest
|
||||
from packages.application.auth.register_user_use_case import RegisterUserUseCase, VerifyEmailRequest, VerifyEmailUseCase
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
username: str
|
||||
display_name: Optional[str] = None
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
user_id: str
|
||||
email: str
|
||||
username: str
|
||||
display_name: str
|
||||
message: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user_id: str
|
||||
email: str
|
||||
username: str
|
||||
display_name: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
class CurrentUserResponse(BaseModel):
|
||||
user_id: str
|
||||
email: str
|
||||
username: str
|
||||
display_name: str
|
||||
email_verified: bool
|
||||
|
||||
|
||||
class PasswordResetRequestModel(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordModel(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class VerifyEmailRequestModel(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
request: RegisterRequest,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
email_service=Depends(get_auth_email_service),
|
||||
):
|
||||
use_case = RegisterUserUseCase(
|
||||
user_repository=user_repository,
|
||||
base_url="http://localhost:3000",
|
||||
email_service=email_service,
|
||||
)
|
||||
response, error = use_case.execute(
|
||||
RegisterUseCaseRequest(
|
||||
email=request.email,
|
||||
password=request.password,
|
||||
username=request.username,
|
||||
display_name=request.display_name or request.username,
|
||||
)
|
||||
)
|
||||
if error or response is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=_translate_auth_error(error))
|
||||
|
||||
return RegisterResponse(
|
||||
user_id=response.user_id,
|
||||
email=response.email,
|
||||
username=response.username,
|
||||
display_name=response.display_name,
|
||||
message="注册成功!",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
request: LoginRequest,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
session_store=Depends(get_auth_session_store),
|
||||
):
|
||||
use_case = LoginUseCase(
|
||||
user_repository=user_repository,
|
||||
session_store=session_store,
|
||||
jwt_secret_key=settings.JWT_SECRET_KEY,
|
||||
)
|
||||
response, error = use_case.execute(LoginUseCaseRequest(email=request.email, password=request.password))
|
||||
if error or response is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
|
||||
|
||||
return LoginResponse(
|
||||
access_token=response.access_token,
|
||||
user_id=response.user_id,
|
||||
email=response.email,
|
||||
username=response.username,
|
||||
display_name=response.display_name,
|
||||
expires_in=response.expires_in,
|
||||
)
|
||||
|
||||
|
||||
def _verify_email_token(token: str, user_repository: UserRepository) -> MessageResponse:
|
||||
success, error = VerifyEmailUseCase(user_repository=user_repository).execute(VerifyEmailRequest(token=token))
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "邮箱验证失败")
|
||||
|
||||
return MessageResponse(message="邮箱验证成功")
|
||||
|
||||
|
||||
@router.get("/verify-email", response_model=MessageResponse)
|
||||
async def verify_email(
|
||||
token: str,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
):
|
||||
return _verify_email_token(token, user_repository)
|
||||
|
||||
|
||||
@router.post("/verify-email", response_model=MessageResponse)
|
||||
async def verify_email_post(
|
||||
request: VerifyEmailRequestModel,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
):
|
||||
return _verify_email_token(request.token, user_repository)
|
||||
|
||||
|
||||
@router.post("/password/forgot", response_model=MessageResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||
@router.post("/forgot-password", response_model=MessageResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||
async def forgot_password(
|
||||
request: PasswordResetRequestModel,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
email_service=Depends(get_auth_email_service),
|
||||
):
|
||||
success, error = RequestPasswordResetUseCase(
|
||||
user_repository=user_repository,
|
||||
base_url="http://localhost:3000",
|
||||
email_service=email_service,
|
||||
).execute(PasswordResetUseCaseRequest(email=request.email))
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "密码重置请求失败")
|
||||
|
||||
return MessageResponse(message="如果账户存在,密码重置邮件已发送")
|
||||
|
||||
|
||||
@router.post("/password/reset", response_model=MessageResponse)
|
||||
@router.post("/reset-password", response_model=MessageResponse)
|
||||
async def reset_password(
|
||||
request: ResetPasswordModel,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
):
|
||||
success, error = ResetPasswordUseCase(user_repository=user_repository).execute(
|
||||
ResetPasswordRequest(token=request.token, new_password=request.new_password)
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "密码重置失败")
|
||||
|
||||
return MessageResponse(message="密码重置成功")
|
||||
|
||||
|
||||
@router.get("/me", response_model=CurrentUserResponse)
|
||||
async def get_current_user_info(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
user = authenticated_user.user
|
||||
return CurrentUserResponse(
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
email_verified=user.email_verified,
|
||||
)
|
||||
|
||||
|
||||
class _NoopSessionStore(NoopSessionStore):
|
||||
pass
|
||||
|
||||
|
||||
class _NoopEmailService(NoopEmailService):
|
||||
pass
|
||||
|
||||
|
||||
def _translate_auth_error(error: str | None) -> str:
|
||||
translations = {
|
||||
"Email already registered": "邮箱已被注册",
|
||||
"Username already taken": "用户名已被使用",
|
||||
"Username is required": "用户名不能为空",
|
||||
"Display name is required": "显示名称不能为空",
|
||||
}
|
||||
return translations.get(error or "", error or "注册失败")
|
||||
@@ -0,0 +1,478 @@
|
||||
"""
|
||||
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,
|
||||
get_workspace_member_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
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
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_workspace_member(
|
||||
workspace_id: str,
|
||||
authenticated_user: AuthenticatedUser,
|
||||
workspace_member_repository: WorkspaceMemberRepository,
|
||||
) -> None:
|
||||
"""Verify user has workspace permission"""
|
||||
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:
|
||||
"""Verify project and asset library exist"""
|
||||
project = GetProjectUseCase(project_repository).execute(project_id)
|
||||
if project is None or project.workspace_id != workspace_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
libraries = asset_library_repository.list_by_project(project_id)
|
||||
if not any(item.id == library_id and item.workspace_id == workspace_id for item in libraries):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
|
||||
|
||||
|
||||
def _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),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_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 and get workspace_id
|
||||
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")
|
||||
|
||||
workspace_id = project.workspace_id
|
||||
|
||||
# Verify permission and asset library
|
||||
_require_workspace_member(workspace_id, authenticated_user, workspace_member_repository)
|
||||
_require_project_and_library(
|
||||
workspace_id,
|
||||
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,
|
||||
"workspace_id": workspace_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),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> 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}",
|
||||
)
|
||||
|
||||
# Verify permission
|
||||
_require_workspace_member(meta["workspace_id"], authenticated_user, workspace_member_repository)
|
||||
|
||||
# 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),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ChunkedUploadStatusResponse:
|
||||
"""Get upload status (for resume)"""
|
||||
meta = _load_upload_meta(upload_id)
|
||||
|
||||
# Verify permission
|
||||
_require_workspace_member(meta["workspace_id"], authenticated_user, workspace_member_repository)
|
||||
|
||||
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),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_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 permission
|
||||
_require_workspace_member(meta["workspace_id"], authenticated_user, workspace_member_repository)
|
||||
|
||||
# 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(
|
||||
workspace_id=meta["workspace_id"],
|
||||
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()
|
||||
@@ -0,0 +1,60 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_classification_job_repository
|
||||
from app.schemas.classification_job import (
|
||||
ClassificationJobResponse,
|
||||
SubmitClassificationJobRequest,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
SubmitClassificationJobCommand,
|
||||
SubmitClassificationJobUseCase,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=ClassificationJobResponse)
|
||||
def get_classification_job(
|
||||
job_id: str,
|
||||
classification_job_repository: Any = Depends(get_classification_job_repository),
|
||||
) -> ClassificationJobResponse:
|
||||
job = classification_job_repository.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail=f"ClassificationJob {job_id} not found")
|
||||
return ClassificationJobResponse(
|
||||
id=job.id,
|
||||
project_id=job.project_id,
|
||||
asset_id=job.asset_id,
|
||||
status=job.status,
|
||||
classification=job.classification,
|
||||
confidence=job.confidence,
|
||||
error_message=job.error_message,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ClassificationJobResponse)
|
||||
def submit_classification_job(
|
||||
request: SubmitClassificationJobRequest,
|
||||
classification_job_repository: Any = Depends(get_classification_job_repository),
|
||||
) -> ClassificationJobResponse:
|
||||
use_case = SubmitClassificationJobUseCase(classification_job_repository)
|
||||
job = use_case.execute(
|
||||
SubmitClassificationJobCommand(
|
||||
project_id=request.project_id,
|
||||
asset_id=request.asset_id,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.classify_asset", args=[job.id])
|
||||
return ClassificationJobResponse(
|
||||
id=job.id,
|
||||
project_id=job.project_id,
|
||||
asset_id=job.asset_id,
|
||||
status=job.status,
|
||||
classification=job.classification,
|
||||
confidence=job.confidence,
|
||||
error_message=job.error_message,
|
||||
)
|
||||
@@ -0,0 +1,308 @@
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from app.api.routes.permissions import require_workspace_member
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_project_repository,
|
||||
get_workspace_member_repository,
|
||||
)
|
||||
from app.schemas.edit_plan import (
|
||||
AutoGenerateEditPlanRequest,
|
||||
CreateEditPlanRequest,
|
||||
EditPlanClipResponse,
|
||||
EditPlanResponse,
|
||||
EditTemplateResponse,
|
||||
)
|
||||
from apps.worker.worker_app.tasks.edit_plan_generator import (
|
||||
EditingMode,
|
||||
SmartEditPlanGenerator,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel, EditPlanModel, EditTemplateModel
|
||||
from packages.domain import AssetStatus
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/edit-plans", tags=["剪辑计划"])
|
||||
|
||||
|
||||
def _ensure_project(project_id: str, workspace_id: str, user: AuthenticatedUser, project_repository, member_repository):
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None or project.workspace_id != workspace_id:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
require_workspace_member(workspace_id, user, member_repository)
|
||||
return project
|
||||
|
||||
|
||||
def _default_template(session: Session, workspace_id: str, project_id: str, user_id: str) -> EditTemplateModel:
|
||||
template = (
|
||||
session.query(EditTemplateModel)
|
||||
.filter(
|
||||
EditTemplateModel.project_id == project_id,
|
||||
EditTemplateModel.is_active.is_(True),
|
||||
)
|
||||
.order_by(EditTemplateModel.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
if template is not None:
|
||||
return template
|
||||
template = EditTemplateModel(
|
||||
id=uuid4().hex,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
name="基础节奏模板",
|
||||
description="自动选择可用视频素材,按上传顺序生成三段式剪辑计划。",
|
||||
target_duration=30,
|
||||
clip_count=3,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
session.add(template)
|
||||
session.commit()
|
||||
return template
|
||||
|
||||
|
||||
def _to_template_response(template: EditTemplateModel) -> EditTemplateResponse:
|
||||
return EditTemplateResponse(
|
||||
id=template.id,
|
||||
workspace_id=template.workspace_id,
|
||||
project_id=template.project_id,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
target_duration=float(template.target_duration or 0),
|
||||
clip_count=int(template.clip_count or 0),
|
||||
is_active=bool(template.is_active),
|
||||
created_at=template.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _to_plan_response(
|
||||
plan: EditPlanModel, clips: list[EditPlanClipModel], asset_names: dict[str, str]
|
||||
) -> EditPlanResponse:
|
||||
return EditPlanResponse(
|
||||
id=plan.id,
|
||||
workspace_id=plan.workspace_id,
|
||||
project_id=plan.project_id,
|
||||
template_id=plan.template_id,
|
||||
asset_library_id=plan.asset_library_id,
|
||||
title_id=plan.title_id,
|
||||
status=plan.status,
|
||||
summary=plan.summary,
|
||||
editing_mode=plan.editing_mode,
|
||||
clips=[
|
||||
EditPlanClipResponse(
|
||||
id=clip.id,
|
||||
asset_id=clip.asset_id,
|
||||
asset_name=asset_names.get(clip.asset_id, clip.asset_id),
|
||||
sequence=clip.sequence,
|
||||
start_time=float(clip.start_time or 0),
|
||||
duration=float(clip.duration or 0),
|
||||
reason=clip.reason,
|
||||
layer=clip.layer,
|
||||
)
|
||||
for clip in clips
|
||||
],
|
||||
created_at=plan.created_at,
|
||||
updated_at=plan.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/templates/", response_model=list[EditTemplateResponse])
|
||||
def list_edit_templates(
|
||||
project_id: str,
|
||||
workspace_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository=Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> list[EditTemplateResponse]:
|
||||
_ensure_project(project_id, workspace_id, authenticated_user, project_repository, workspace_member_repository)
|
||||
template = _default_template(session, workspace_id, project_id, authenticated_user.user.id)
|
||||
templates = (
|
||||
session.query(EditTemplateModel)
|
||||
.filter(EditTemplateModel.project_id == project_id, EditTemplateModel.is_active.is_(True))
|
||||
.all()
|
||||
)
|
||||
return [_to_template_response(item) for item in templates or [template]]
|
||||
|
||||
|
||||
@router.post("", response_model=EditPlanResponse)
|
||||
def create_edit_plan(
|
||||
project_id: str,
|
||||
request: CreateEditPlanRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository=Depends(get_project_repository),
|
||||
asset_repository=Depends(get_asset_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> EditPlanResponse:
|
||||
_ensure_project(
|
||||
project_id, request.workspace_id, authenticated_user, project_repository, workspace_member_repository
|
||||
)
|
||||
template = (
|
||||
session.query(EditTemplateModel).filter(EditTemplateModel.id == request.template_id).first()
|
||||
if request.template_id
|
||||
else None
|
||||
)
|
||||
if template is None:
|
||||
template = _default_template(session, request.workspace_id, project_id, authenticated_user.user.id)
|
||||
assets = [
|
||||
asset
|
||||
for asset in asset_repository.list_by_library(request.asset_library_id)
|
||||
if asset.status == AssetStatus.READY and asset.mime_type.startswith("video/")
|
||||
]
|
||||
if not assets:
|
||||
raise HTTPException(status_code=422, detail="素材库暂无可用于剪辑计划的视频素材")
|
||||
selected = sorted(assets, key=lambda asset: (-(asset.quality_score or 0), asset.created_at))[
|
||||
: max(1, int(template.clip_count or 3))
|
||||
]
|
||||
plan = EditPlanModel(
|
||||
id=uuid4().hex,
|
||||
workspace_id=request.workspace_id,
|
||||
project_id=project_id,
|
||||
template_id=template.id,
|
||||
asset_library_id=request.asset_library_id,
|
||||
title_id=request.title_id,
|
||||
status="draft",
|
||||
summary=f"按《{template.name}》自动选择 {len(selected)} 段素材,预计生成约 {int(template.target_duration or 30)} 秒成片。",
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(plan)
|
||||
clips: list[EditPlanClipModel] = []
|
||||
clip_duration = max(1, float(template.target_duration or 30) / len(selected))
|
||||
for index, asset in enumerate(selected, start=1):
|
||||
clip = EditPlanClipModel(
|
||||
id=uuid4().hex,
|
||||
edit_plan_id=plan.id,
|
||||
asset_id=asset.id,
|
||||
sequence=index,
|
||||
start_time=0,
|
||||
duration=min(float(asset.duration or clip_duration), clip_duration),
|
||||
reason="优先选择已就绪、质量分较高的视频素材。",
|
||||
)
|
||||
session.add(clip)
|
||||
clips.append(clip)
|
||||
session.commit()
|
||||
return _to_plan_response(plan, clips, {asset.id: asset.name for asset in selected})
|
||||
|
||||
|
||||
@router.post("/auto-generate", response_model=EditPlanResponse)
|
||||
def auto_generate_edit_plan(
|
||||
project_id: str,
|
||||
request: AutoGenerateEditPlanRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository=Depends(get_project_repository),
|
||||
asset_repository=Depends(get_asset_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> EditPlanResponse:
|
||||
"""
|
||||
智能生成剪辑计划
|
||||
|
||||
根据素材的分类结果和质量评分,自动编排剪辑计划。
|
||||
支持多种剪辑模式:
|
||||
- one_take: 按分类分组,组内按质量排序,顺序拼接
|
||||
- pip: 第一个高质量素材为主画面,其余为画中画
|
||||
- voice_over: person 类素材为主播口播,其余穿插为 B-roll
|
||||
- voice_pip: 结合 voice_over 和 pip,第一个高质量 person 素材为主画面
|
||||
"""
|
||||
_ensure_project(
|
||||
project_id, request.workspace_id, authenticated_user, project_repository, workspace_member_repository
|
||||
)
|
||||
|
||||
# 获取素材库中的所有素材
|
||||
assets = asset_repository.list_by_library(request.asset_library_id)
|
||||
|
||||
if not assets:
|
||||
raise HTTPException(status_code=422, detail="素材库中暂无素材")
|
||||
|
||||
# 使用智能生成器
|
||||
generator = SmartEditPlanGenerator(project_id, assets)
|
||||
|
||||
try:
|
||||
plan_result = generator.generate_plan(
|
||||
editing_mode=request.editing_mode,
|
||||
target_duration=request.target_duration
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
if not plan_result.clips:
|
||||
raise HTTPException(status_code=422, detail="无符合条件的视频素材")
|
||||
|
||||
# 获取模板
|
||||
template = (
|
||||
session.query(EditTemplateModel).filter(EditTemplateModel.id == request.template_id).first()
|
||||
if request.template_id
|
||||
else None
|
||||
)
|
||||
if template is None:
|
||||
template = _default_template(session, request.workspace_id, project_id, authenticated_user.user.id)
|
||||
|
||||
# 创建剪辑计划
|
||||
plan = EditPlanModel(
|
||||
id=uuid4().hex,
|
||||
workspace_id=request.workspace_id,
|
||||
project_id=project_id,
|
||||
template_id=template.id,
|
||||
asset_library_id=request.asset_library_id,
|
||||
title_id=request.title_id or "",
|
||||
status="draft",
|
||||
editing_mode=request.editing_mode,
|
||||
summary=plan_result.summary,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(plan)
|
||||
|
||||
# 创建剪辑片段
|
||||
clips: list[EditPlanClipModel] = []
|
||||
asset_name_map = {asset.id: asset.name for asset in assets}
|
||||
|
||||
for clip_plan in plan_result.clips:
|
||||
clip = EditPlanClipModel(
|
||||
id=uuid4().hex,
|
||||
edit_plan_id=plan.id,
|
||||
asset_id=clip_plan.asset_id,
|
||||
sequence=clip_plan.sequence,
|
||||
start_time=clip_plan.start_time,
|
||||
duration=clip_plan.duration,
|
||||
reason=clip_plan.reason,
|
||||
layer=clip_plan.layer,
|
||||
)
|
||||
session.add(clip)
|
||||
clips.append(clip)
|
||||
|
||||
session.commit()
|
||||
|
||||
return _to_plan_response(plan, clips, asset_name_map)
|
||||
|
||||
|
||||
@router.get("/{plan_id}", response_model=EditPlanResponse)
|
||||
def get_edit_plan(
|
||||
project_id: str,
|
||||
plan_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository=Depends(get_project_repository),
|
||||
asset_repository=Depends(get_asset_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> EditPlanResponse:
|
||||
plan = (
|
||||
session.query(EditPlanModel).filter(EditPlanModel.id == plan_id, EditPlanModel.project_id == project_id).first()
|
||||
)
|
||||
if plan is None:
|
||||
raise HTTPException(status_code=404, detail="Edit plan not found")
|
||||
_ensure_project(project_id, plan.workspace_id, authenticated_user, project_repository, workspace_member_repository)
|
||||
clips = (
|
||||
session.query(EditPlanClipModel)
|
||||
.filter(EditPlanClipModel.edit_plan_id == plan.id)
|
||||
.order_by(EditPlanClipModel.sequence.asc())
|
||||
.all()
|
||||
)
|
||||
assets = asset_repository.list_by_library(plan.asset_library_id)
|
||||
return _to_plan_response(plan, clips, {asset.id: asset.name for asset in assets})
|
||||
@@ -0,0 +1,122 @@
|
||||
from typing import Any
|
||||
|
||||
from app.api.routes.permissions import require_workspace_member
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import get_generated_video_repository, get_project_repository, get_workspace_member_repository
|
||||
from app.schemas.generated_video import (
|
||||
GeneratedVideoDownloadUrlResponse,
|
||||
GeneratedVideoResponse,
|
||||
ListGeneratedVideosResponse,
|
||||
UpdateGeneratedVideoReviewRequest,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
GetGeneratedVideoDownloadUrlUseCase,
|
||||
GetGeneratedVideoUseCase,
|
||||
ListGeneratedVideosUseCase,
|
||||
)
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_generated_video_response(item, download_url: str | None = None) -> GeneratedVideoResponse:
|
||||
return GeneratedVideoResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
generation_task_id=item.generation_task_id,
|
||||
name=item.name,
|
||||
file_url=item.file_url,
|
||||
file_size=item.file_size,
|
||||
duration=item.duration,
|
||||
thumbnail_url=item.thumbnail_url,
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
fps=item.fps,
|
||||
status=item.status,
|
||||
review_status=item.review_status,
|
||||
generation_params=item.generation_params,
|
||||
download_url=download_url,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ListGeneratedVideosResponse)
|
||||
def list_generated_videos(
|
||||
project_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> ListGeneratedVideosResponse:
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
|
||||
use_case = ListGeneratedVideosUseCase(generated_video_repository)
|
||||
items = use_case.execute(project_id)
|
||||
# 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)
|
||||
|
||||
|
||||
@router.get("/{video_id}", response_model=GeneratedVideoResponse)
|
||||
def get_generated_video(
|
||||
video_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> GeneratedVideoResponse:
|
||||
use_case = GetGeneratedVideoUseCase(generated_video_repository)
|
||||
item = use_case.execute(video_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
||||
require_workspace_member(item.workspace_id, authenticated_user, workspace_member_repository)
|
||||
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),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_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")
|
||||
require_workspace_member(video.workspace_id, authenticated_user, workspace_member_repository)
|
||||
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)
|
||||
|
||||
|
||||
@router.get("/{video_id}/download-url", response_model=GeneratedVideoDownloadUrlResponse)
|
||||
def get_generated_video_download_url(
|
||||
video_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> GeneratedVideoDownloadUrlResponse:
|
||||
video = generated_video_repository.get(video_id)
|
||||
if video is None:
|
||||
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
||||
require_workspace_member(video.workspace_id, authenticated_user, workspace_member_repository)
|
||||
use_case = GetGeneratedVideoDownloadUrlUseCase(generated_video_repository)
|
||||
file_url = use_case.execute(video_id)
|
||||
if file_url is None:
|
||||
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
||||
download_url = storage_service.get_download_url(file_url)
|
||||
return GeneratedVideoDownloadUrlResponse(video_id=video_id, download_url=download_url)
|
||||
@@ -0,0 +1,166 @@
|
||||
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_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
get_project_repository,
|
||||
get_project_title_repository,
|
||||
)
|
||||
from app.schemas.generated_video import (
|
||||
GeneratedVideoResponse,
|
||||
ListGeneratedVideosResponse,
|
||||
)
|
||||
from app.schemas.generation_task import (
|
||||
CreateGenerationTaskRequest,
|
||||
GenerationTaskResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
GetGenerationTaskUseCase,
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
|
||||
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,
|
||||
project_id=task.project_id,
|
||||
asset_library_id=task.asset_library_id,
|
||||
strategy_id=task.strategy_id,
|
||||
voice_library_id=task.voice_library_id,
|
||||
edit_plan_id=task.edit_plan_id,
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
result_count=task.result_count,
|
||||
error_message=task.error_message,
|
||||
)
|
||||
|
||||
|
||||
def _to_generated_video_response(item) -> GeneratedVideoResponse:
|
||||
return GeneratedVideoResponse(
|
||||
id=item.id,
|
||||
project_id=item.project_id,
|
||||
generation_task_id=item.generation_task_id,
|
||||
name=item.name,
|
||||
file_url=item.file_url,
|
||||
file_size=item.file_size,
|
||||
duration=item.duration,
|
||||
thumbnail_url=item.thumbnail_url,
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
fps=item.fps,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_library_has_ready_video_assets(assets) -> None:
|
||||
ready_video_assets = [
|
||||
asset for asset in assets if asset.status.value == "ready" and asset.mime_type.startswith("video")
|
||||
]
|
||||
if not ready_video_assets:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="当前素材库没有 ready 状态的视频素材,请先上传并等待导入完成后再生成。",
|
||||
)
|
||||
|
||||
|
||||
def _select_title_id(project_title_repository: Any, project_id: str) -> str:
|
||||
active_titles = project_title_repository.list_by_project(project_id, active_only=True)
|
||||
if not active_titles:
|
||||
return ""
|
||||
selected = sorted(
|
||||
active_titles,
|
||||
key=lambda title: (
|
||||
0 if getattr(title, "favorite", False) else 1,
|
||||
int(title.usage_count or 0),
|
||||
title.created_at,
|
||||
),
|
||||
)[0]
|
||||
return selected.id
|
||||
|
||||
|
||||
@router.post("/tasks/", response_model=GenerationTaskResponse)
|
||||
def create_generation_task(
|
||||
request: CreateGenerationTaskRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_title_repository: Any = Depends(get_project_title_repository),
|
||||
) -> GenerationTaskResponse:
|
||||
project = project_repository.find_by_id(request.project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found")
|
||||
if not project.can_access(authenticated_user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
library = asset_library_repository.get(request.asset_library_id)
|
||||
if library is None or library.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(
|
||||
project_id=request.project_id,
|
||||
asset_library_id=request.asset_library_id,
|
||||
strategy_id=request.strategy_id or _select_title_id(project_title_repository, request.project_id),
|
||||
voice_library_id=request.voice_library_id,
|
||||
edit_plan_id=request.edit_plan_id,
|
||||
created_by_user_id=authenticated_user.id,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
return _to_generation_task_response(task)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}", response_model=GenerationTaskResponse)
|
||||
def get_generation_task(
|
||||
task_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> GenerationTaskResponse:
|
||||
use_case = GetGenerationTaskUseCase(generation_task_repository)
|
||||
task = use_case.execute(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
|
||||
_check_project_access(task.project_id, authenticated_user.id, project_repository)
|
||||
return _to_generation_task_response(task)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/results/", response_model=ListGeneratedVideosResponse)
|
||||
def list_generation_results(
|
||||
task_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ListGeneratedVideosResponse:
|
||||
task = generation_task_repository.get(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
|
||||
_check_project_access(task.project_id, authenticated_user.id, project_repository)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
|
||||
items = use_case.execute(task_id)
|
||||
return ListGeneratedVideosResponse(items=[_to_generated_video_response(item) for item in items])
|
||||
@@ -1,10 +1,119 @@
|
||||
from fastapi import APIRouter
|
||||
from datetime import datetime
|
||||
|
||||
from app.schemas.health import HealthResponse
|
||||
import psycopg2
|
||||
import redis
|
||||
from app.config import settings
|
||||
from fastapi import APIRouter, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(tags=["Health"])
|
||||
|
||||
|
||||
@router.get("", response_model=HealthResponse)
|
||||
def get_health() -> HealthResponse:
|
||||
return HealthResponse(ok=True, service="api")
|
||||
@router.get("/health", status_code=status.HTTP_200_OK)
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"version": settings.APP_VERSION,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/ready", status_code=status.HTTP_200_OK)
|
||||
async def readiness_check():
|
||||
checks = {
|
||||
"database": await _check_database(),
|
||||
"redis": await _check_redis(),
|
||||
}
|
||||
all_healthy = all(check["status"] == "healthy" for check in checks.values())
|
||||
response = {
|
||||
"status": "ready" if all_healthy else "not_ready",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"checks": checks,
|
||||
}
|
||||
if not all_healthy:
|
||||
return JSONResponse(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content=response)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/startup", status_code=status.HTTP_200_OK)
|
||||
async def startup_check():
|
||||
checks = {
|
||||
"database": await _check_database(),
|
||||
"migrations": await _check_migrations(),
|
||||
}
|
||||
all_ready = all(check["status"] == "healthy" for check in checks.values())
|
||||
response = {
|
||||
"status": "started" if all_ready else "starting",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"checks": checks,
|
||||
}
|
||||
if not all_ready:
|
||||
return JSONResponse(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content=response)
|
||||
return response
|
||||
|
||||
|
||||
async def _check_database() -> dict:
|
||||
if settings.USE_IN_MEMORY_DB:
|
||||
return {
|
||||
"status": "healthy",
|
||||
"type": "in_memory",
|
||||
"message": "Using in-memory database",
|
||||
}
|
||||
try:
|
||||
conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1")
|
||||
cur.fetchone()
|
||||
conn.close()
|
||||
return {
|
||||
"status": "healthy",
|
||||
"type": "postgresql",
|
||||
"message": "Database connection successful",
|
||||
}
|
||||
except Exception as error:
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"type": "postgresql",
|
||||
"message": f"Database connection failed: {error}",
|
||||
}
|
||||
|
||||
|
||||
async def _check_redis() -> dict:
|
||||
try:
|
||||
client = redis.from_url(settings.REDIS_URL, socket_connect_timeout=3)
|
||||
client.ping()
|
||||
client.close()
|
||||
return {
|
||||
"status": "healthy",
|
||||
"type": "redis",
|
||||
"message": "Redis connection successful",
|
||||
}
|
||||
except Exception as error:
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"type": "redis",
|
||||
"message": f"Redis connection failed: {error}",
|
||||
}
|
||||
|
||||
|
||||
async def _check_migrations() -> dict:
|
||||
if settings.USE_IN_MEMORY_DB:
|
||||
return {
|
||||
"status": "healthy",
|
||||
"message": "Using in-memory database, no migrations needed",
|
||||
}
|
||||
try:
|
||||
conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_name IN ('projects', 'asset_libraries', 'assets', 'ingest_jobs', 'classification_jobs')
|
||||
""")
|
||||
count = cur.fetchone()[0]
|
||||
conn.close()
|
||||
if count >= 5:
|
||||
return {"status": "healthy", "message": "Database migrations applied"}
|
||||
return {"status": "unhealthy", "message": f"Missing tables, found {count}/5"}
|
||||
except Exception as error:
|
||||
return {"status": "unhealthy", "message": f"Migration check failed: {error}"}
|
||||
|
||||
@@ -1,34 +1,52 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from typing import Any
|
||||
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_ingest_job_repository
|
||||
from app.schemas.ingest_job import IngestJobResponse, SubmitIngestJobRequest
|
||||
from packages.adapters.in_memory import InMemoryIngestJobRepository
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
from apps.worker.worker_app.tasks.ingest import ingest_asset
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=IngestJobResponse)
|
||||
def submit_ingest_job(
|
||||
request: SubmitIngestJobRequest,
|
||||
ingest_job_repository: InMemoryIngestJobRepository = Depends(get_ingest_job_repository),
|
||||
@router.get("/{job_id}", response_model=IngestJobResponse)
|
||||
def get_ingest_job(
|
||||
job_id: str,
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
) -> IngestJobResponse:
|
||||
job = ingest_job_repository.get(job_id)
|
||||
if job is None:
|
||||
raise ValueError(f"IngestJob {job_id} not found")
|
||||
return IngestJobResponse(
|
||||
id=job.id,
|
||||
project_id=job.project_id,
|
||||
library_id=job.library_id,
|
||||
storage_key=job.storage_key,
|
||||
status=job.status.value,
|
||||
error_message=job.error_message,
|
||||
result_asset_id=job.result_asset_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=IngestJobResponse)
|
||||
def submit_ingest_job(
|
||||
request: SubmitIngestJobRequest,
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
) -> IngestJobResponse:
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
job = use_case.execute(
|
||||
SubmitIngestJobCommand(
|
||||
workspace_id=request.workspace_id,
|
||||
project_id=request.project_id,
|
||||
library_id=request.library_id,
|
||||
storage_key=request.storage_key,
|
||||
)
|
||||
)
|
||||
# Enqueue async worker task
|
||||
ingest_asset.delay(job.id)
|
||||
|
||||
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
|
||||
return IngestJobResponse(
|
||||
id=job.id,
|
||||
workspace_id=job.workspace_id,
|
||||
project_id=job.project_id,
|
||||
library_id=job.library_id,
|
||||
storage_key=job.storage_key,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
from typing import Any
|
||||
|
||||
from app.api.routes.permissions import require_workspace_member
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_project_repository,
|
||||
get_project_title_repository,
|
||||
get_workspace_member_repository,
|
||||
)
|
||||
from app.schemas.project_title import (
|
||||
CreateProjectTitleRequest,
|
||||
ListProjectTitlesResponse,
|
||||
ProjectTitleResponse,
|
||||
UpdateProjectTitleRequest,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_response(item) -> ProjectTitleResponse:
|
||||
return ProjectTitleResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
text=item.text,
|
||||
category=item.category,
|
||||
favorite=bool(getattr(item, "favorite", False)),
|
||||
usage_count=int(item.usage_count or 0),
|
||||
is_active=bool(item.is_active),
|
||||
created_at=item.created_at,
|
||||
updated_at=item.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _get_project_or_404(project_id: str, project_repository: Any):
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
return project
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/titles", response_model=ListProjectTitlesResponse)
|
||||
def list_project_titles(
|
||||
project_id: str,
|
||||
active_only: bool = False,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
title_repository: Any = Depends(get_project_title_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ListProjectTitlesResponse:
|
||||
project = _get_project_or_404(project_id, project_repository)
|
||||
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
|
||||
return ListProjectTitlesResponse(
|
||||
items=[_to_response(item) for item in title_repository.list_by_project(project_id, active_only)]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/titles", response_model=ProjectTitleResponse)
|
||||
def create_project_title(
|
||||
project_id: str,
|
||||
request: CreateProjectTitleRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
title_repository: Any = Depends(get_project_title_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ProjectTitleResponse:
|
||||
project = _get_project_or_404(project_id, project_repository)
|
||||
if project.workspace_id != request.workspace_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="workspace_id does not match project")
|
||||
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
|
||||
item = title_repository.create(
|
||||
workspace_id=project.workspace_id,
|
||||
project_id=project_id,
|
||||
text=request.text,
|
||||
category=request.category,
|
||||
favorite=request.favorite,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
)
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@router.patch("/project-titles/{title_id}", response_model=ProjectTitleResponse)
|
||||
def update_project_title(
|
||||
title_id: str,
|
||||
request: UpdateProjectTitleRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
title_repository: Any = Depends(get_project_title_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ProjectTitleResponse:
|
||||
item = title_repository.get(title_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project title not found")
|
||||
require_workspace_member(item.workspace_id, authenticated_user, workspace_member_repository)
|
||||
if request.text is not None:
|
||||
item.text = request.text.strip()
|
||||
if request.category is not None:
|
||||
item.category = request.category
|
||||
if request.favorite is not None:
|
||||
item.favorite = request.favorite
|
||||
if request.is_active is not None:
|
||||
item.is_active = request.is_active
|
||||
return _to_response(title_repository.update(item))
|
||||
@@ -1,38 +1,71 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from typing import Any
|
||||
|
||||
from app.dependencies import get_project_repository
|
||||
from app.schemas.project import CreateProjectRequest, ListProjectsResponse, ProjectResponse
|
||||
from packages.application import CreateProjectCommand, CreateProjectUseCase, ListProjectsUseCase
|
||||
from packages.adapters.in_memory import InMemoryProjectRepository
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_project_repository, get_workspace_member_repository
|
||||
from app.schemas.project import (
|
||||
CreateProjectRequest,
|
||||
ListProjectsResponse,
|
||||
ProjectResponse,
|
||||
)
|
||||
from app.api.routes.permissions import require_workspace_member
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from packages.application import (
|
||||
CreateProjectCommand,
|
||||
CreateProjectUseCase,
|
||||
GetProjectUseCase,
|
||||
ListProjectsUseCase,
|
||||
)
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_project_response(item) -> ProjectResponse:
|
||||
return ProjectResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectResponse)
|
||||
def get_project(
|
||||
project_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ProjectResponse:
|
||||
use_case = GetProjectUseCase(project_repository)
|
||||
project = use_case.execute(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
|
||||
return _to_project_response(project)
|
||||
|
||||
|
||||
@router.get("", response_model=ListProjectsResponse)
|
||||
def list_projects(
|
||||
workspace_id: str,
|
||||
project_repository: InMemoryProjectRepository = Depends(get_project_repository),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ListProjectsResponse:
|
||||
require_workspace_member(workspace_id, authenticated_user, workspace_member_repository)
|
||||
use_case = ListProjectsUseCase(project_repository)
|
||||
projects = use_case.execute(workspace_id)
|
||||
return ListProjectsResponse(
|
||||
items=[
|
||||
ProjectResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
)
|
||||
for item in projects
|
||||
]
|
||||
)
|
||||
return ListProjectsResponse(items=[_to_project_response(item) for item in projects])
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectResponse)
|
||||
def create_project(
|
||||
request: CreateProjectRequest,
|
||||
project_repository: InMemoryProjectRepository = Depends(get_project_repository),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ProjectResponse:
|
||||
require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository)
|
||||
use_case = CreateProjectUseCase(project_repository)
|
||||
project = use_case.execute(
|
||||
CreateProjectCommand(
|
||||
@@ -41,9 +74,4 @@ def create_project(
|
||||
description=request.description,
|
||||
)
|
||||
)
|
||||
return ProjectResponse(
|
||||
id=project.id,
|
||||
workspace_id=project.workspace_id,
|
||||
name=project.name,
|
||||
description=project.description,
|
||||
)
|
||||
return _to_project_response(project)
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
from typing import Any
|
||||
|
||||
from app.api.routes.permissions import require_workspace_member
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import (
|
||||
get_generation_task_repository,
|
||||
get_ingest_job_repository,
|
||||
get_project_repository,
|
||||
get_workspace_member_repository,
|
||||
)
|
||||
from app.schemas.task_center import ListProjectTasksResponse, ProjectTaskResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
SubmitIngestJobCommand,
|
||||
SubmitIngestJobUseCase,
|
||||
)
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _humanize_task_error(error_message: str) -> str:
|
||||
raw = (error_message or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
lower = raw.lower()
|
||||
if "ffmpeg" in lower or "ffprobe" in lower or "invalid data" in lower or "moov atom" in lower:
|
||||
return "视频素材格式无法识别,请重新导出为常见 MP4/H.264 后再试。"
|
||||
if "oss" in lower or "bucket" in lower or "storage" in lower:
|
||||
return "素材存储服务读取或写入失败,请稍后重试或联系小虾检查 OSS。"
|
||||
if "not found" in lower or "no such file" in lower:
|
||||
return "任务依赖的素材或文件不存在,请确认素材仍在项目中。"
|
||||
return f"任务失败:{raw}"
|
||||
|
||||
|
||||
def _generation_step(task) -> str:
|
||||
if task.status.value == "pending":
|
||||
return "等待 Worker 执行"
|
||||
if task.status.value == "running":
|
||||
return "正在生成成片"
|
||||
if task.status.value == "completed":
|
||||
return "生成完成"
|
||||
if task.status.value == "failed":
|
||||
return "生成失败"
|
||||
return task.status.value
|
||||
|
||||
|
||||
def _ingest_step(job) -> str:
|
||||
if job.status.value == "pending":
|
||||
return "等待导入"
|
||||
if job.status.value == "processing":
|
||||
return "正在分析素材"
|
||||
if job.status.value == "completed":
|
||||
return "导入完成"
|
||||
if job.status.value == "failed":
|
||||
return "导入失败"
|
||||
return job.status.value
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse)
|
||||
def list_project_tasks(
|
||||
project_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ListProjectTasksResponse:
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
|
||||
|
||||
items: list[ProjectTaskResponse] = []
|
||||
for job in ingest_job_repository.list_by_project(project_id):
|
||||
items.append(
|
||||
ProjectTaskResponse(
|
||||
id=f"ingest:{job.id}",
|
||||
task_type="ingest",
|
||||
workspace_id=job.workspace_id,
|
||||
project_id=job.project_id,
|
||||
status=job.status.value,
|
||||
progress=100.0 if job.status.value == "completed" else 0.0,
|
||||
current_step=_ingest_step(job),
|
||||
error_message=job.error_message,
|
||||
user_message=_humanize_task_error(job.error_message),
|
||||
retryable=job.status.value == "failed",
|
||||
source_id=job.id,
|
||||
created_at=job.created_at,
|
||||
updated_at=job.updated_at,
|
||||
)
|
||||
)
|
||||
for task in generation_task_repository.list_by_project(project_id):
|
||||
items.append(
|
||||
ProjectTaskResponse(
|
||||
id=f"generation:{task.id}",
|
||||
task_type="generation",
|
||||
workspace_id=task.workspace_id,
|
||||
project_id=task.project_id,
|
||||
status=task.status.value,
|
||||
progress=task.progress,
|
||||
current_step=_generation_step(task),
|
||||
error_message=task.error_message,
|
||||
user_message=_humanize_task_error(task.error_message),
|
||||
retryable=task.status.value == "failed",
|
||||
source_id=task.id,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.completed_at or task.started_at or task.created_at,
|
||||
)
|
||||
)
|
||||
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
|
||||
return ListProjectTasksResponse(items=items)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_type}/{source_id}/retry", response_model=ProjectTaskResponse)
|
||||
def retry_project_task(
|
||||
task_type: str,
|
||||
source_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ProjectTaskResponse:
|
||||
if task_type == "generation":
|
||||
task = generation_task_repository.get(source_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Generation task not found")
|
||||
require_workspace_member(task.workspace_id, authenticated_user, workspace_member_repository)
|
||||
if task.status.value != "failed":
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
retried = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
workspace_id=task.workspace_id,
|
||||
project_id=task.project_id,
|
||||
asset_library_id=task.asset_library_id,
|
||||
strategy_id=task.strategy_id,
|
||||
voice_library_id=task.voice_library_id,
|
||||
edit_plan_id=task.edit_plan_id,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[retried.id])
|
||||
return ProjectTaskResponse(
|
||||
id=f"generation:{retried.id}",
|
||||
task_type="generation",
|
||||
workspace_id=retried.workspace_id,
|
||||
project_id=retried.project_id,
|
||||
status=retried.status.value,
|
||||
progress=retried.progress,
|
||||
current_step=_generation_step(retried),
|
||||
source_id=retried.id,
|
||||
created_at=retried.created_at,
|
||||
updated_at=retried.created_at,
|
||||
)
|
||||
if task_type == "ingest":
|
||||
job = ingest_job_repository.get(source_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Ingest job not found")
|
||||
require_workspace_member(job.workspace_id, authenticated_user, workspace_member_repository)
|
||||
if job.status.value != "failed":
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
retried = use_case.execute(
|
||||
SubmitIngestJobCommand(
|
||||
workspace_id=job.workspace_id,
|
||||
project_id=job.project_id,
|
||||
library_id=job.library_id,
|
||||
storage_key=job.storage_key,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.ingest_asset", args=[retried.id])
|
||||
return ProjectTaskResponse(
|
||||
id=f"ingest:{retried.id}",
|
||||
task_type="ingest",
|
||||
workspace_id=retried.workspace_id,
|
||||
project_id=retried.project_id,
|
||||
status=retried.status.value,
|
||||
progress=0,
|
||||
current_step=_ingest_step(retried),
|
||||
source_id=retried.id,
|
||||
created_at=retried.created_at,
|
||||
updated_at=retried.updated_at,
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Unsupported task type")
|
||||
@@ -1,80 +1,88 @@
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Form
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from typing import Optional
|
||||
|
||||
from app.dependencies import get_ingest_job_repository
|
||||
from app.core.storage import get_minio_service, MinIOService
|
||||
from app.schemas.upload import UploadAssetResponse
|
||||
from packages.adapters.in_memory import InMemoryIngestJobRepository
|
||||
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
from apps.worker.worker_app.tasks.ingest import ingest_asset
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.api.routes.permissions import require_workspace_member
|
||||
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,
|
||||
UploadAssetResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
|
||||
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 允许上传的文件 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",
|
||||
})
|
||||
|
||||
@router.post("", response_model=UploadAssetResponse)
|
||||
async def upload_asset(
|
||||
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
|
||||
workspace_id: str = Form(..., description="工作空间 ID"),
|
||||
project_id: str = Form(..., description="项目 ID"),
|
||||
library_id: str = Form(..., description="资产库 ID"),
|
||||
ingest_job_repository: InMemoryIngestJobRepository = Depends(get_ingest_job_repository),
|
||||
storage_service: MinIOService = Depends(get_minio_service),
|
||||
) -> UploadAssetResponse:
|
||||
"""
|
||||
上传素材文件并触发导入流水线。
|
||||
|
||||
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()
|
||||
|
||||
1. **接收文件**:支持 multipart/form-data 上传
|
||||
2. **存储到 MinIO**:自动存储到对象存储
|
||||
3. **生成存储键**:格式为 `uploads/{id}/{filename}`
|
||||
4. **提交导入任务**:创建 IngestJob 记录
|
||||
5. **异步处理**:通过 Celery 队列处理
|
||||
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.",
|
||||
)
|
||||
|
||||
## 支持的文件类型
|
||||
|
||||
- **视频**:MP4, MOV, AVI, MKV 等
|
||||
- **音频**:MP3, WAV, AAC 等
|
||||
- **图片**:JPG, PNG, GIF, WebP 等
|
||||
|
||||
## 请求示例
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/api/v1/upload" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F "file=@/path/to/video.mp4" \
|
||||
-F "workspace_id=ws_123" \
|
||||
-F "project_id=proj_456" \
|
||||
-F "library_id=lib_789"
|
||||
```
|
||||
|
||||
## 响应说明
|
||||
|
||||
- `storage_key`: 文件在 MinIO 中的存储路径
|
||||
- `ingest_job_id`: 导入任务 ID,用于追踪处理状态
|
||||
- `url`: 文件的公开访问 URL
|
||||
|
||||
## 后续流程
|
||||
|
||||
上传成功后,系统会:
|
||||
1. 自动提取文件元数据(时长、分辨率等)
|
||||
2. 生成缩略图
|
||||
3. 进行场景分割(视频)
|
||||
4. 创建 Asset 记录
|
||||
"""
|
||||
# Generate storage key
|
||||
file_id = uuid4().hex[:8]
|
||||
storage_key = f"uploads/{file_id}/{file.filename}"
|
||||
|
||||
# Upload file to MinIO
|
||||
file_url = storage_service.upload_file(
|
||||
file.file,
|
||||
storage_key,
|
||||
content_type=file.content_type or "application/octet-stream",
|
||||
)
|
||||
|
||||
# Submit ingest job
|
||||
return base_type
|
||||
|
||||
|
||||
def _require_project_and_library(
|
||||
workspace_id: str,
|
||||
project_id: str,
|
||||
library_id: str,
|
||||
project_repository: Any,
|
||||
asset_library_repository: Any,
|
||||
) -> None:
|
||||
project = GetProjectUseCase(project_repository).execute(project_id)
|
||||
if project is None or project.workspace_id != workspace_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
libraries = asset_library_repository.list_by_project(project_id)
|
||||
if not any(item.id == library_id and item.workspace_id == workspace_id for item in libraries):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
|
||||
|
||||
|
||||
def _submit_ingest_job(
|
||||
workspace_id: str,
|
||||
project_id: str,
|
||||
library_id: str,
|
||||
storage_key: str,
|
||||
ingest_job_repository: Any,
|
||||
) -> Any:
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
job = use_case.execute(
|
||||
SubmitIngestJobCommand(
|
||||
@@ -84,10 +92,136 @@ async def upload_asset(
|
||||
storage_key=storage_key,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
return job
|
||||
|
||||
|
||||
@router.post("/direct/prepare", response_model=DirectUploadPrepareResponse)
|
||||
async def prepare_direct_upload(
|
||||
request: DirectUploadPrepareRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> DirectUploadPrepareResponse:
|
||||
"""创建浏览器直传 OSS 的短期表单签名。"""
|
||||
settings = get_settings()
|
||||
max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024
|
||||
if request.file_size > max_size_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"File exceeds upload limit ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
|
||||
)
|
||||
|
||||
# P2-5: 服务端验证 MIME 类型
|
||||
validated_content_type = _validate_mime_type(request.content_type)
|
||||
|
||||
# Enqueue async worker task
|
||||
ingest_asset.delay(job.id)
|
||||
|
||||
require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository)
|
||||
_require_project_and_library(
|
||||
request.workspace_id,
|
||||
request.project_id,
|
||||
request.library_id,
|
||||
project_repository,
|
||||
asset_library_repository,
|
||||
)
|
||||
|
||||
file_id = uuid4().hex[:8]
|
||||
safe_filename = request.filename.replace("/", "_").replace("\\", "_")
|
||||
storage_key = f"uploads/{file_id}/{safe_filename}"
|
||||
try:
|
||||
payload = storage_service.create_direct_upload_post(
|
||||
storage_key=storage_key,
|
||||
content_type=validated_content_type,
|
||||
max_size_bytes=max_size_bytes,
|
||||
expires_seconds=settings.OSS_DIRECT_UPLOAD_EXPIRE_SECONDS,
|
||||
)
|
||||
except RuntimeError as error:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(error)) from error
|
||||
|
||||
return DirectUploadPrepareResponse(
|
||||
upload_url=str(payload["url"]),
|
||||
method=str(payload["method"]),
|
||||
storage_key=str(payload["storage_key"]),
|
||||
expires_at=str(payload["expires_at"]),
|
||||
fields={str(key): str(value) for key, value in dict(payload["fields"]).items()},
|
||||
max_size_bytes=max_size_bytes,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/direct/complete", response_model=DirectUploadCompleteResponse)
|
||||
async def complete_direct_upload(
|
||||
request: DirectUploadCompleteRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> DirectUploadCompleteResponse:
|
||||
"""确认浏览器直传完成并创建导入任务。"""
|
||||
require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository)
|
||||
_require_project_and_library(
|
||||
request.workspace_id,
|
||||
request.project_id,
|
||||
request.library_id,
|
||||
project_repository,
|
||||
asset_library_repository,
|
||||
)
|
||||
normalized_key = storage_service._normalize_storage_key(request.storage_key)
|
||||
if not normalized_key.startswith("uploads/"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid upload key")
|
||||
if not storage_service.file_exists(normalized_key):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found")
|
||||
|
||||
job = _submit_ingest_job(
|
||||
workspace_id=request.workspace_id,
|
||||
project_id=request.project_id,
|
||||
library_id=request.library_id,
|
||||
storage_key=normalized_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
)
|
||||
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
|
||||
|
||||
|
||||
@router.post("", response_model=UploadAssetResponse)
|
||||
async def upload_asset(
|
||||
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
|
||||
workspace_id: str = Form(..., description="工作空间 ID"),
|
||||
project_id: str = Form(..., description="项目 ID"),
|
||||
library_id: str = Form(..., description="素材库 ID"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> UploadAssetResponse:
|
||||
"""上传素材文件并触发导入流水线。"""
|
||||
require_workspace_member(workspace_id, authenticated_user, workspace_member_repository)
|
||||
_require_project_and_library(workspace_id, project_id, library_id, project_repository, asset_library_repository)
|
||||
|
||||
# P2-5: 服务端验证 MIME 类型
|
||||
validated_content_type = _validate_mime_type(file.content_type)
|
||||
|
||||
file_id = uuid4().hex[:8]
|
||||
safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else "unknown"
|
||||
storage_key = f"uploads/{file_id}/{safe_filename}"
|
||||
|
||||
file_url = storage_service.upload_file(
|
||||
file.file,
|
||||
storage_key,
|
||||
content_type=validated_content_type,
|
||||
)
|
||||
|
||||
job = _submit_ingest_job(
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
storage_key=storage_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
)
|
||||
|
||||
return UploadAssetResponse(
|
||||
storage_key=storage_key,
|
||||
ingest_job_id=job.id,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import jwt
|
||||
from app.config import settings
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jwt import ExpiredSignatureError, InvalidTokenError
|
||||
|
||||
from packages.domain.entities import User
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
from .dependencies import get_user_repository
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthenticatedUser:
|
||||
user: User
|
||||
session_id: str | None = None
|
||||
token_type: str | None = None
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> AuthenticatedUser:
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise _unauthorized("Missing bearer token")
|
||||
|
||||
payload = _decode_user_token(credentials.credentials)
|
||||
user_id = payload.get("sub")
|
||||
if not isinstance(user_id, str) or not user_id:
|
||||
raise _unauthorized("Invalid token subject")
|
||||
|
||||
user = user_repository.find_by_id(user_id)
|
||||
if user is None:
|
||||
raise _unauthorized("User no longer exists")
|
||||
|
||||
return AuthenticatedUser(
|
||||
user=user,
|
||||
session_id=payload.get("sid"),
|
||||
token_type=payload.get("type"),
|
||||
)
|
||||
|
||||
|
||||
def _decode_user_token(token: str) -> dict:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
|
||||
except ExpiredSignatureError:
|
||||
raise _unauthorized("Token expired") from None
|
||||
except InvalidTokenError:
|
||||
raise _unauthorized("Invalid token") from None
|
||||
|
||||
if payload.get("type") not in {"user_auth", "access"}:
|
||||
raise _unauthorized("Invalid token type")
|
||||
return payload
|
||||
|
||||
|
||||
def _unauthorized(detail: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=detail,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
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"
|
||||
ENVIRONMENT: str = "development"
|
||||
DEBUG: bool = True
|
||||
|
||||
# Container bind address; external expose is controlled by Docker/Nginx.
|
||||
API_HOST: str = "0.0.0.0" # nosec: B104
|
||||
API_PORT: int = 8000
|
||||
API_PREFIX: str = "/api/v1"
|
||||
|
||||
DATABASE_URL: str = (
|
||||
"postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
|
||||
)
|
||||
DATABASE_POOL_SIZE: int = 20
|
||||
DATABASE_MAX_OVERFLOW: int = 40
|
||||
DATABASE_POOL_TIMEOUT: int = 30
|
||||
DATABASE_POOL_RECYLE: 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
|
||||
ENABLE_REDIS_SESSION: bool = False
|
||||
|
||||
# JWT secret key - MUST be set via environment variable, no default allowed
|
||||
JWT_SECRET_KEY: Optional[str] = None
|
||||
|
||||
@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
|
||||
|
||||
ENABLE_EMAIL_DELIVERY: bool = False
|
||||
SMTP_HOST: str = "smtp.gmail.com"
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM_EMAIL: str = ""
|
||||
SMTP_FRON_NAME: str = "小虾 SaaS"
|
||||
SMTP_USE_TLS: bool = True
|
||||
|
||||
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
|
||||
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
|
||||
|
||||
# OSS 七牛云相关
|
||||
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliiyuncs.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
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
CORS_ORIGINS_RAW: str = (
|
||||
"http://localhost:3000,http://localhost:5173,http://localhost:8000"
|
||||
)
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
@property
|
||||
def CORS_ORIGINS(self) -> list[str]:
|
||||
return [
|
||||
origin.strip()
|
||||
for origin in self.CORS_ORIGINS_RAW.split(",")
|
||||
if origin.strip()
|
||||
]
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
return self.DATABASE_URL
|
||||
|
||||
@property
|
||||
def redis_url(self) -> str:
|
||||
return self.REDIS_URL
|
||||
|
||||
@property
|
||||
def celery_broker_url(self) -> str:
|
||||
return self.CELERY_BROKER_URL
|
||||
|
||||
@property
|
||||
def celery_result_backend(self) -> str:
|
||||
return self.CELERY_RESULT_BACKEND
|
||||
|
||||
@property
|
||||
def oss_endpoint(self) -> str:
|
||||
return self.OSS_ENDPOINT
|
||||
|
||||
@property
|
||||
def oss_access_key_id(self) -> str:
|
||||
return self.OSS_ACCESS_KEY_ID
|
||||
|
||||
@property
|
||||
def oss_access_key_secret(self) -> str:
|
||||
return self.OSS_ACCESS_KEY_SECRET
|
||||
|
||||
@property
|
||||
def oss_bucket_name(self) -> str:
|
||||
return self.OSS_BUCKET_NAME
|
||||
|
||||
|
||||
_settings: Optional[Settings] = None
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
global _settings
|
||||
if _settings is None:
|
||||
env = os.getenv("APP_ENV", "development")
|
||||
env_file = f".env.{env}" if env != "development" else ".env"
|
||||
if os.path.exists(env_file):
|
||||
_settings = Settings(_env_file=env_file)
|
||||
else:
|
||||
_settings = Settings()
|
||||
return _settings
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,7 @@
|
||||
from app.config import get_settings
|
||||
from celery import Celery
|
||||
|
||||
settings = get_settings()
|
||||
celery_app = Celery("xiaoxia-saas-api")
|
||||
celery_app.conf.broker_url = settings.CELERY_BROKER_URL
|
||||
celery_app.conf.result_backend = settings.CELERY_RESULT_BACKEND
|
||||
@@ -1,11 +1,19 @@
|
||||
from pydantic import BaseModel
|
||||
"""Compatibility layer for the canonical API settings module.
|
||||
|
||||
Use `app.config` as the single source of truth for API configuration.
|
||||
This module remains only for older imports during migration.
|
||||
"""
|
||||
|
||||
from app.config import Settings as AppSettings
|
||||
from app.config import get_settings, settings
|
||||
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
app_name: str = "xiaoxia-saas-api"
|
||||
app_env: str = "development"
|
||||
api_prefix: str = "/api"
|
||||
def reload_settings() -> AppSettings:
|
||||
"""Reload settings for tests and legacy callers."""
|
||||
import app.config as canonical_config
|
||||
|
||||
canonical_config.settings = canonical_config.get_settings()
|
||||
return canonical_config.settings
|
||||
|
||||
|
||||
def get_settings() -> AppSettings:
|
||||
return AppSettings()
|
||||
__all__ = ["AppSettings", "get_settings", "reload_settings", "settings"]
|
||||
|
||||
@@ -1,9 +1,49 @@
|
||||
from pydantic import BaseModel
|
||||
"""Database session management and engine configuration.
|
||||
|
||||
统一使用 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
|
||||
|
||||
|
||||
class DatabaseSettings(BaseModel):
|
||||
database_url: str = "postgresql://postgres:postgres@postgres:5432/xiaoxia_saas"
|
||||
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,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def get_database_settings() -> DatabaseSettings:
|
||||
return DatabaseSettings()
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""Dependency for getting database sessions."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
+211
-89
@@ -1,110 +1,232 @@
|
||||
"""MinIO storage service for file uploads."""
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
from typing import BinaryIO
|
||||
"""阿里云 OSS 存储服务"""
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
import oss2
|
||||
except ImportError: # pragma: no cover - exercised in minimal local/test environments
|
||||
oss2 = None
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MinIOService:
|
||||
"""MinIO storage service."""
|
||||
|
||||
def __init__(
|
||||
class OSSStorageService:
|
||||
"""阿里云 OSS 存储服务"""
|
||||
|
||||
def __init__(self):
|
||||
settings = get_settings()
|
||||
self.bucket_name = settings.OSS_BUCKET_NAME
|
||||
self.public_url = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
|
||||
self.local_url_prefix = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files")
|
||||
self.bucket = None
|
||||
|
||||
if settings.OSS_ACCESS_KEY_ID and settings.OSS_ACCESS_KEY_SECRET:
|
||||
if oss2 is not None:
|
||||
auth = oss2.Auth(
|
||||
settings.OSS_ACCESS_KEY_ID,
|
||||
settings.OSS_ACCESS_KEY_SECRET,
|
||||
)
|
||||
self.bucket = oss2.Bucket(
|
||||
auth,
|
||||
settings.OSS_ENDPOINT,
|
||||
settings.OSS_BUCKET_NAME,
|
||||
)
|
||||
self.access_key_id = settings.OSS_ACCESS_KEY_ID
|
||||
self.access_key_secret = settings.OSS_ACCESS_KEY_SECRET
|
||||
self.endpoint = settings.OSS_ENDPOINT
|
||||
|
||||
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
|
||||
parsed = urlparse(storage_key_or_url)
|
||||
path = parsed.path if parsed.scheme else storage_key_or_url
|
||||
return path.startswith(f"{self.local_url_prefix}/")
|
||||
|
||||
def create_direct_upload_post(
|
||||
self,
|
||||
endpoint: str = "47.98.113.167:9000",
|
||||
access_key: str = "admin",
|
||||
secret_key: str = "xiaoxia2026",
|
||||
bucket_name: str = "xiaoxia-assets",
|
||||
secure: bool = False,
|
||||
):
|
||||
"""Initialize MinIO client."""
|
||||
self.client = Minio(
|
||||
endpoint,
|
||||
access_key=access_key,
|
||||
secret_key=secret_key,
|
||||
secure=secure,
|
||||
storage_key: str,
|
||||
content_type: str,
|
||||
max_size_bytes: int,
|
||||
expires_seconds: int,
|
||||
) -> dict[str, object]:
|
||||
"""创建浏览器直传 OSS 的 PostObject 表单。"""
|
||||
if not self.access_key_id or not self.access_key_secret:
|
||||
raise RuntimeError("OSS storage is not configured")
|
||||
normalized_key = self._normalize_storage_key(storage_key)
|
||||
if not normalized_key.startswith("uploads/"):
|
||||
raise ValueError("direct upload key must be under uploads/")
|
||||
|
||||
expiration = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=expires_seconds)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S.000Z"
|
||||
)
|
||||
self.bucket_name = bucket_name
|
||||
self._ensure_bucket()
|
||||
|
||||
def _ensure_bucket(self):
|
||||
"""Ensure bucket exists."""
|
||||
try:
|
||||
if not self.client.bucket_exists(self.bucket_name):
|
||||
self.client.make_bucket(self.bucket_name)
|
||||
# Set download policy for public access
|
||||
policy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": [f"arn:aws:s3:::{self.bucket_name}/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
import json
|
||||
self.client.set_bucket_policy(self.bucket_name, json.dumps(policy))
|
||||
except S3Error as e:
|
||||
print(f"Error ensuring bucket: {e}")
|
||||
|
||||
policy = {
|
||||
"expiration": expiration,
|
||||
"conditions": [
|
||||
{"bucket": self.bucket_name},
|
||||
{"key": normalized_key},
|
||||
["content-length-range", 1, max_size_bytes],
|
||||
["starts-with", "$Content-Type", content_type.split("/", 1)[0] + "/" if "/" in content_type else ""],
|
||||
],
|
||||
}
|
||||
encoded_policy = base64.b64encode(json.dumps(policy, separators=(",", ":")).encode("utf-8")).decode("ascii")
|
||||
signature = base64.b64encode(
|
||||
hmac.new(self.access_key_secret.encode("utf-8"), encoded_policy.encode("utf-8"), hashlib.sha1).digest()
|
||||
).decode("ascii")
|
||||
|
||||
return {
|
||||
"url": self.public_url,
|
||||
"method": "POST",
|
||||
"storage_key": normalized_key,
|
||||
"expires_at": expiration,
|
||||
"fields": {
|
||||
"key": normalized_key,
|
||||
"OSSAccessKeyId": self.access_key_id,
|
||||
"policy": encoded_policy,
|
||||
"Signature": signature,
|
||||
"success_action_status": "201",
|
||||
"Content-Type": content_type,
|
||||
},
|
||||
}
|
||||
|
||||
def upload_file(
|
||||
self,
|
||||
file: BinaryIO,
|
||||
file_or_path,
|
||||
storage_key: str,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> str:
|
||||
"""
|
||||
Upload file to MinIO.
|
||||
|
||||
上传文件到 OSS
|
||||
|
||||
Args:
|
||||
file: File object to upload
|
||||
storage_key: Storage path/key (e.g., "uploads/abc123/video.mp4")
|
||||
content_type: MIME type of the file
|
||||
|
||||
file_or_path: 文件对象或本地文件路径
|
||||
storage_key: 存储键(文件路径)
|
||||
content_type: 内容类型
|
||||
|
||||
Returns:
|
||||
str: Public URL of uploaded file
|
||||
文件公网 URL
|
||||
"""
|
||||
if self.bucket is None:
|
||||
raise RuntimeError("OSS storage is not configured")
|
||||
|
||||
try:
|
||||
# Get file size
|
||||
file.seek(0, os.SEEK_END)
|
||||
file_size = file.tell()
|
||||
file.seek(0)
|
||||
|
||||
# Upload file
|
||||
self.client.put_object(
|
||||
self.bucket_name,
|
||||
storage_key,
|
||||
file,
|
||||
file_size,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
# Return public URL
|
||||
return f"http://47.98.113.167:9000/{self.bucket_name}/{storage_key}"
|
||||
|
||||
except S3Error as e:
|
||||
raise Exception(f"Failed to upload file: {e}")
|
||||
|
||||
# 如果是字符串路径,从本地文件上传
|
||||
if isinstance(file_or_path, str):
|
||||
self.bucket.put_object_from_file(storage_key, file_or_path, headers={"Content-Type": content_type})
|
||||
else:
|
||||
# 文件对象
|
||||
file_or_path.seek(0)
|
||||
self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type})
|
||||
|
||||
return f"{self.public_url}/{storage_key}"
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to upload file to OSS: {e}")
|
||||
|
||||
def get_url(self, storage_key: str) -> str:
|
||||
"""Get public URL for a storage key."""
|
||||
return f"http://47.98.113.167:9000/{self.bucket_name}/{storage_key}"
|
||||
|
||||
def delete_file(self, storage_key: str):
|
||||
"""Delete file from MinIO."""
|
||||
"""获取文件公网 URL"""
|
||||
return f"{self.public_url}/{storage_key}"
|
||||
|
||||
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
|
||||
"""
|
||||
获取文件下载签名 URL(用于私有文件)
|
||||
|
||||
Args:
|
||||
storage_key_or_url: 存储键或完整 URL
|
||||
expires_seconds: 过期时间(秒)
|
||||
|
||||
Returns:
|
||||
签名 URL
|
||||
"""
|
||||
if self.bucket is None:
|
||||
if self._is_local_generated_url(storage_key_or_url):
|
||||
return storage_key_or_url
|
||||
return self.get_url(self._normalize_storage_key(storage_key_or_url))
|
||||
|
||||
storage_key = self._normalize_storage_key(storage_key_or_url)
|
||||
try:
|
||||
self.client.remove_object(self.bucket_name, storage_key)
|
||||
except S3Error as e:
|
||||
print(f"Error deleting file: {e}")
|
||||
return self.bucket.sign_url("GET", storage_key, expires_seconds)
|
||||
except Exception:
|
||||
return self.get_url(storage_key)
|
||||
|
||||
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
|
||||
"""从 URL 中提取存储键"""
|
||||
if storage_key_or_url.startswith("http://") or storage_key_or_url.startswith("https://"):
|
||||
parsed = urlparse(storage_key_or_url)
|
||||
# 移除开头的 /
|
||||
return parsed.path.lstrip("/")
|
||||
return storage_key_or_url.lstrip("/")
|
||||
|
||||
def download_file(self, storage_key: str, local_path: str):
|
||||
"""
|
||||
从 OSS 下载文件到本地
|
||||
|
||||
Args:
|
||||
storage_key: 存储键
|
||||
local_path: 本地文件路径
|
||||
"""
|
||||
if self.bucket is None:
|
||||
raise RuntimeError("OSS storage is not configured")
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
||||
self.bucket.get_object_to_file(storage_key, local_path)
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to download file from OSS: {e}")
|
||||
|
||||
def delete_file(self, storage_key: str):
|
||||
"""
|
||||
删除 OSS 文件
|
||||
|
||||
Args:
|
||||
storage_key: 存储键
|
||||
"""
|
||||
if self.bucket is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.bucket.delete_object(storage_key)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"Failed to delete file from OSS",
|
||||
extra={"storage_key": storage_key, "error": str(error)},
|
||||
)
|
||||
|
||||
def file_exists(self, storage_key: str) -> bool:
|
||||
"""
|
||||
检查文件是否存在
|
||||
|
||||
Args:
|
||||
storage_key: 存储键
|
||||
|
||||
Returns:
|
||||
是否存在
|
||||
"""
|
||||
if self.bucket is None:
|
||||
return False
|
||||
return self.bucket.object_exists(storage_key)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_minio_service = None
|
||||
# 向后兼容的服务类名
|
||||
MinIOService = OSSStorageService
|
||||
|
||||
|
||||
def get_minio_service() -> MinIOService:
|
||||
"""Get or create MinIO service instance."""
|
||||
global _minio_service
|
||||
if _minio_service is None:
|
||||
_minio_service = MinIOService()
|
||||
return _minio_service
|
||||
_storage_service = None
|
||||
|
||||
|
||||
def get_storage_service() -> OSSStorageService:
|
||||
"""获取存储服务实例(全局单例)"""
|
||||
global _storage_service
|
||||
if _storage_service is None:
|
||||
_storage_service = OSSStorageService()
|
||||
return _storage_service
|
||||
|
||||
|
||||
# 向后兼容的别名
|
||||
def get_minio_service() -> OSSStorageService:
|
||||
"""向后兼容:返回 OSS 服务(已替换 MinIO)"""
|
||||
return get_storage_service()
|
||||
|
||||
+22
-8
@@ -1,15 +1,29 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from collections.abc import Generator
|
||||
|
||||
from app.core.database import get_database_settings
|
||||
from app.config import settings
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
settings = get_database_settings()
|
||||
engine = create_engine(settings.database_url)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
from packages.adapters.sqlalchemy_impl import (
|
||||
build_session_factory,
|
||||
ensure_database_exists,
|
||||
initialize_database,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.schema_guard import assert_auto_create_schema_allowed
|
||||
|
||||
ensure_database_exists(settings.DATABASE_URL)
|
||||
engine, SessionLocal = build_session_factory(
|
||||
settings.DATABASE_URL,
|
||||
pool_size=settings.DATABASE_POOL_SIZE,
|
||||
max_overflow=settings.DATABASE_MAX_OVERFLOW,
|
||||
pool_timeout=settings.DATABASE_POOL_TIMEOUT,
|
||||
pool_recycle=settings.DATABASE_POOL_RECYCLE,
|
||||
)
|
||||
assert_auto_create_schema_allowed(settings.ENVIRONMENT, settings.AUTO_CREATE_SCHEMA)
|
||||
if settings.AUTO_CREATE_SCHEMA:
|
||||
initialize_database(engine)
|
||||
|
||||
|
||||
def get_db() -> Session:
|
||||
"""Dependency for database session."""
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
|
||||
+165
-18
@@ -1,28 +1,175 @@
|
||||
from functools import lru_cache
|
||||
"""Dependency injection providers for FastAPI endpoints.
|
||||
|
||||
from packages.adapters.in_memory import (
|
||||
InMemoryAssetLibraryRepository,
|
||||
InMemoryAssetRepository,
|
||||
InMemoryIngestJobRepository,
|
||||
InMemoryProjectRepository,
|
||||
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
|
||||
|
||||
from packages.adapters.redis import NoopSessionStore, SessionStore
|
||||
from packages.adapters.smtp import EmailConfig, NoopEmailService, get_email_service
|
||||
from packages.adapters.sqlalchemy_impl.asset_library_repository import (
|
||||
SQLAlchemyAssetLibraryRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.classification_job_repository import (
|
||||
SQLAlchemyClassificationJobRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.ingest_job_repository import (
|
||||
SQLAlchemyIngestJobRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.project_repository import (
|
||||
SQLAlchemyProjectRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.project_title_repository import (
|
||||
SQLAlchemyProjectTitleRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.session import build_session_factory
|
||||
from packages.adapters.sqlalchemy_impl.user_repository import SQLAlchemyUserRepository
|
||||
from packages.adapters.sqlalchemy_impl.workspace_invitation_repository import SQLAlchemyWorkspaceInvitationRepository
|
||||
from packages.adapters.sqlalchemy_impl.workspace_member_repository import SQLAlchemyWorkspaceMemberRepository
|
||||
from packages.adapters.sqlalchemy_impl.workspace_repository import SQLAlchemyWorkspaceRepository
|
||||
from packages.ports.asset_repository import AssetRepository
|
||||
from packages.ports.asset_library_repository import AssetLibraryRepository
|
||||
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
|
||||
from packages.ports.classification_job_repository import ClassificationJobRepository
|
||||
from packages.ports.generation_task_repository import GenerationTaskRepository
|
||||
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.project_title_repository import ProjectTitleRepository
|
||||
|
||||
_engine, _SessionLocal = build_session_factory(settings.DATABASE_URL)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_project_repository() -> InMemoryProjectRepository:
|
||||
return InMemoryProjectRepository()
|
||||
def get_db_session() -> Generator[Session, None, None]:
|
||||
"""Provide a database session with automatic cleanup."""
|
||||
session: Session = _SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_asset_library_repository() -> InMemoryAssetLibraryRepository:
|
||||
return InMemoryAssetLibraryRepository()
|
||||
def get_asset_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyAssetRepository:
|
||||
"""Provide the SQLAlchemy asset repository implementation."""
|
||||
return SQLAlchemyAssetRepository(session)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_asset_repository() -> InMemoryAssetRepository:
|
||||
return InMemoryAssetRepository()
|
||||
def get_asset_library_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyAssetLibraryRepository:
|
||||
"""Provide the SQLAlchemy asset library repository implementation."""
|
||||
return SQLAlchemyAssetLibraryRepository(session)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_ingest_job_repository() -> InMemoryIngestJobRepository:
|
||||
return InMemoryIngestJobRepository()
|
||||
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_project_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyProjectRepository:
|
||||
"""Provide the SQLAlchemy project repository implementation."""
|
||||
return SQLAlchemyProjectRepository(session)
|
||||
|
||||
|
||||
def get_project_title_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyProjectTitleRepository:
|
||||
"""Provide the SQLAlchemy project title repository implementation."""
|
||||
return SQLAlchemyProjectTitleRepository(session)
|
||||
|
||||
|
||||
def get_user_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> UserRepository:
|
||||
"""Provide the SQLAlchemy user repository implementation."""
|
||||
return SQLAlchemyUserRepository(session)
|
||||
|
||||
|
||||
def get_workspace_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> WorkspaceRepository:
|
||||
"""Provide the SQLAlchemy workspace repository implementation."""
|
||||
return SQLAlchemyWorkspaceRepository(session)
|
||||
|
||||
|
||||
def get_workspace_member_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> WorkspaceMemberRepository:
|
||||
"""Provide the SQLAlchemy workspace member repository implementation."""
|
||||
return SQLAlchemyWorkspaceMemberRepository(session)
|
||||
|
||||
|
||||
def get_workspace_invitation_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> WorkspaceInvitationRepository:
|
||||
"""Provide the SQLAlchemy workspace invitation repository implementation."""
|
||||
return SQLAlchemyWorkspaceInvitationRepository(session)
|
||||
|
||||
|
||||
def get_auth_session_store() -> SessionStore | NoopSessionStore:
|
||||
"""Provide the session store based on configuration."""
|
||||
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."""
|
||||
if not settings.ENABLE_EMAIL_DELIVERY:
|
||||
return NoopEmailService()
|
||||
return get_email_service(
|
||||
EmailConfig(
|
||||
smtp_host=settings.SMTP_HOST,
|
||||
smtp_port=settings.SMTP_PORT,
|
||||
smtp_user=settings.SMTP_USER,
|
||||
smtp_password=settings.SMTP_PASSWORD,
|
||||
from_email=settings.SMTP_FROM_EMAIL,
|
||||
from_name=settings.SMTP_FROM_NAME,
|
||||
use_tls=settings.SMTP_USE_TLS,
|
||||
),
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Authentication dependency compatibility layer.
|
||||
|
||||
Canonical bearer-token parsing lives in app.auth. This module remains only so
|
||||
legacy imports have a safe target while workspace dependencies are rebuilt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.auth import AuthenticatedUser
|
||||
from app.auth import get_current_user as get_authenticated_user
|
||||
from app.dependencies import get_user_repository, get_workspace_member_repository
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from packages.domain.entities import User
|
||||
from packages.domain.permissions import PermissionChecker
|
||||
from packages.ports.user_repository import UserRepository
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
optional_bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_authenticated_user),
|
||||
) -> User:
|
||||
return authenticated_user.user
|
||||
|
||||
|
||||
async def get_current_user_optional(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer_scheme),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> User | None:
|
||||
if credentials is None:
|
||||
return None
|
||||
try:
|
||||
authenticated_user = await get_authenticated_user(credentials, user_repository)
|
||||
except HTTPException:
|
||||
return None
|
||||
return authenticated_user.user
|
||||
|
||||
|
||||
def require_workspace_access(
|
||||
workspace_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> tuple[str, str]:
|
||||
has_access, role = PermissionChecker(workspace_member_repository).check_workspace_access(workspace_id, user.id)
|
||||
if not has_access or role is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this workspace")
|
||||
return workspace_id, role
|
||||
|
||||
|
||||
def require_workspace_admin(
|
||||
workspace_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> str:
|
||||
if not PermissionChecker(workspace_member_repository).check_is_admin_or_owner(workspace_id, user.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only workspace owner or admin can perform this action",
|
||||
)
|
||||
return workspace_id
|
||||
|
||||
|
||||
def require_workspace_owner(
|
||||
workspace_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> str:
|
||||
if not PermissionChecker(workspace_member_repository).check_is_owner(workspace_id, user.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Only workspace owner can perform this action"
|
||||
)
|
||||
return workspace_id
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
全局异常处理和错误响应
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from fastapi import Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class APIException(Exception):
|
||||
"""API 异常基类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = status.HTTP_400_BAD_REQUEST,
|
||||
error_code: str = "API_ERROR",
|
||||
):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.error_code = error_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class AuthenticationError(APIException):
|
||||
"""认证错误"""
|
||||
|
||||
def __init__(self, message: str = "Authentication failed"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
error_code="AUTH_ERROR",
|
||||
)
|
||||
|
||||
|
||||
class PermissionDeniedError(APIException):
|
||||
"""权限拒绝"""
|
||||
|
||||
def __init__(self, message: str = "Permission denied"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
error_code="PERMISSION_DENIED",
|
||||
)
|
||||
|
||||
|
||||
class ResourceNotFoundError(APIException):
|
||||
"""资源不存在"""
|
||||
|
||||
def __init__(self, resource: str = "Resource"):
|
||||
super().__init__(
|
||||
message=f"{resource} not found",
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
error_code="NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class ValidationError(APIException):
|
||||
"""验证错误"""
|
||||
|
||||
def __init__(self, message: str):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
error_code="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
|
||||
async def api_exception_handler(request: Request, exc: APIException):
|
||||
"""API 异常处理"""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"error": {
|
||||
"code": exc.error_code,
|
||||
"message": exc.message,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
||||
"""HTTP 异常处理"""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"error": {
|
||||
"code": f"HTTP_{exc.status_code}",
|
||||
"message": exc.detail,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""请求验证异常处理"""
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
errors.append(
|
||||
{
|
||||
"field": ".".join(str(loc) for loc in error["loc"]),
|
||||
"message": error["msg"],
|
||||
"type": error["type"],
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Request validation failed",
|
||||
"details": errors,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""通用异常处理"""
|
||||
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
||||
|
||||
# 生产环境不返回详细错误信息
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"error": {
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": "An internal error occurred",
|
||||
# "detail": str(exc), # 仅在开发环境启用
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""请求日志和限流中间件"""
|
||||
|
||||
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):
|
||||
"""请求日志中间件"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# 记录请求开始时间
|
||||
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}")
|
||||
|
||||
# 处理请求
|
||||
response = await call_next(request)
|
||||
|
||||
# 记录请求结束时间
|
||||
process_time = time.time() - start_time
|
||||
|
||||
# 记录响应信息
|
||||
logger.info(
|
||||
f"Response: {request.method} {request.url.path} "
|
||||
f"status={response.status_code} time={process_time:.3f}s"
|
||||
)
|
||||
|
||||
# 添加处理时间到响应头
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""基于 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]}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# 获取客户端 IP
|
||||
client_ip = request.client.host
|
||||
|
||||
current_time = time.time()
|
||||
|
||||
# 清理过期记录
|
||||
if client_ip in self.requests:
|
||||
self.requests[client_ip] = [
|
||||
ts
|
||||
for ts in self.requests[client_ip]
|
||||
if current_time - ts < self.window_seconds
|
||||
]
|
||||
|
||||
# 计算请求次数
|
||||
request_count = len(self.requests.get(client_ip, []))
|
||||
|
||||
if request_count >= self.max_requests:
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
"error": {
|
||||
"code": "RATE_LIMIT_EXCEEDED",
|
||||
"message": ( # noqa: E501
|
||||
f"Too many requests. Limit: "
|
||||
f"{self.max_requests} per {self.window_seconds}s"
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# 记录请求
|
||||
if client_ip not in self.requests:
|
||||
self.requests[client_ip] = []
|
||||
self.requests[client_ip].append(current_time)
|
||||
|
||||
# 处理请求
|
||||
response = await call_next(request)
|
||||
|
||||
# 添加限流信息到响应头
|
||||
response.headers["X-RateLimit-Limit"] = str(self.max_requests)
|
||||
response.headers["X-RateLimit-Remaining"] = str(
|
||||
self.max_requests - len(self.requests[client_ip])
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
性能监控中间件
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PerformanceMonitoringMiddleware(BaseHTTPMiddleware):
|
||||
"""性能监控中间件"""
|
||||
|
||||
def __init__(self, app, slow_request_threshold: float = 1.0):
|
||||
super().__init__(app)
|
||||
self.slow_request_threshold = slow_request_threshold # 慢请求阈值(秒)
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable):
|
||||
# 记录请求开始时间
|
||||
start_time = time.time()
|
||||
|
||||
# 生成请求 ID
|
||||
request_id = self._generate_request_id()
|
||||
request.state.request_id = request_id
|
||||
|
||||
# 处理请求
|
||||
try:
|
||||
response = await call_next(request)
|
||||
|
||||
# 计算处理时间
|
||||
process_time = time.time() - start_time
|
||||
|
||||
# 添加响应头
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
response.headers["X-Process-Time"] = f"{process_time:.3f}"
|
||||
|
||||
# 记录慢请求
|
||||
if process_time > self.slow_request_threshold:
|
||||
logger.warning(
|
||||
f"Slow request detected: {request.method} {request.url.path} "
|
||||
f"took {process_time:.3f}s (threshold: {self.slow_request_threshold}s) "
|
||||
f"[request_id={request_id}]"
|
||||
)
|
||||
|
||||
# 记录请求日志
|
||||
logger.info(
|
||||
f"{request.method} {request.url.path} "
|
||||
f"status={response.status_code} time={process_time:.3f}s "
|
||||
f"[request_id={request_id}]"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
process_time = time.time() - start_time
|
||||
logger.error(
|
||||
f"Request failed: {request.method} {request.url.path} "
|
||||
f"error={str(e)} time={process_time:.3f}s "
|
||||
f"[request_id={request_id}]",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
def _generate_request_id(self) -> str:
|
||||
"""生成请求 ID"""
|
||||
import uuid
|
||||
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class DatabaseQueryLogger:
|
||||
"""数据库查询日志记录器"""
|
||||
|
||||
def __init__(self):
|
||||
self.queries = []
|
||||
self.total_time = 0
|
||||
|
||||
def log_query(self, query: str, params: tuple, duration: float):
|
||||
"""记录查询"""
|
||||
self.queries.append(
|
||||
{
|
||||
"query": query,
|
||||
"params": params,
|
||||
"duration": duration,
|
||||
}
|
||||
)
|
||||
self.total_time += duration
|
||||
|
||||
# 记录慢查询(超过 100ms)
|
||||
if duration > 0.1:
|
||||
logger.warning(f"Slow query detected: {query[:100]}... " f"took {duration:.3f}s with params {params}")
|
||||
|
||||
def get_stats(self):
|
||||
"""获取统计信息"""
|
||||
return {
|
||||
"total_queries": len(self.queries),
|
||||
"total_time": self.total_time,
|
||||
"avg_time": self.total_time / len(self.queries) if self.queries else 0,
|
||||
"slow_queries": len([q for q in self.queries if q["duration"] > 0.1]),
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
API 版本管理中间件
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
|
||||
class APIVersionMiddleware(BaseHTTPMiddleware):
|
||||
"""API 版本管理中间件"""
|
||||
|
||||
# 版本配置
|
||||
VERSIONS = {
|
||||
"v1": {
|
||||
"status": "stable",
|
||||
"deprecated": False,
|
||||
"sunset_date": None,
|
||||
"release_date": "2026-06-17",
|
||||
},
|
||||
"v2": {
|
||||
"status": "development",
|
||||
"deprecated": False,
|
||||
"sunset_date": None,
|
||||
"release_date": None,
|
||||
},
|
||||
}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# 提取版本号
|
||||
version = self._extract_version(request.url.path)
|
||||
|
||||
# 处理请求
|
||||
response = await call_next(request)
|
||||
|
||||
# 添加版本信息头
|
||||
if version:
|
||||
response.headers["X-API-Version"] = version
|
||||
|
||||
# 添加弃用警告
|
||||
version_info = self.VERSIONS.get(version, {})
|
||||
if version_info.get("deprecated"):
|
||||
response.headers["X-API-Deprecated"] = "true"
|
||||
|
||||
sunset_date = version_info.get("sunset_date")
|
||||
if sunset_date:
|
||||
response.headers["X-API-Sunset-Date"] = sunset_date
|
||||
|
||||
response.headers["X-API-Deprecation-Info"] = f"https://docs.xiaoxia-saas.com/api/deprecation/{version}"
|
||||
|
||||
return response
|
||||
|
||||
def _extract_version(self, path: str) -> str:
|
||||
"""从路径中提取版本号"""
|
||||
parts = path.split("/")
|
||||
for part in parts:
|
||||
if part.startswith("v") and part[1:].isdigit():
|
||||
return part
|
||||
return None
|
||||
|
||||
|
||||
class VersionNotFoundMiddleware(BaseHTTPMiddleware):
|
||||
"""处理已下线的 API 版本"""
|
||||
|
||||
SUNSET_VERSIONS = [] # 已下线的版本列表
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
version = self._extract_version(request.url.path)
|
||||
|
||||
if version in self.SUNSET_VERSIONS:
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
return JSONResponse(
|
||||
status_code=410,
|
||||
content={
|
||||
"error": {
|
||||
"code": "API_VERSION_SUNSET",
|
||||
"message": f"API {version} has been sunset and is no longer available",
|
||||
"sunset_date": "2028-07-01",
|
||||
"migration_guide": f"https://docs.xiaoxia-saas.com/api/migration/{version}",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
def _extract_version(self, path: str) -> str:
|
||||
"""从路径中提取版本号"""
|
||||
parts = path.split("/")
|
||||
for part in parts:
|
||||
if part.startswith("v") and part[1:].isdigit():
|
||||
return part
|
||||
return None
|
||||
@@ -1,11 +1,15 @@
|
||||
"""Schema package."""
|
||||
|
||||
from .asset import AssetResponse, CreateAssetRequest, ListAssetsResponse
|
||||
from .asset_library import AssetLibraryResponse, CreateAssetLibraryRequest, ListAssetLibrariesResponse
|
||||
from .asset_library import (
|
||||
AssetLibraryResponse,
|
||||
CreateAssetLibraryRequest,
|
||||
ListAssetLibrariesResponse,
|
||||
)
|
||||
from .health import HealthResponse
|
||||
from .ingest_job import IngestJobResponse, SubmitIngestJobRequest
|
||||
from .project import CreateProjectRequest, ListProjectsResponse, ProjectResponse
|
||||
from .upload import UploadAssetRequest, UploadAssetResponse
|
||||
from .upload import UploadAssetResponse
|
||||
|
||||
__all__ = [
|
||||
"AssetResponse",
|
||||
@@ -20,6 +24,5 @@ __all__ = [
|
||||
"ListProjectsResponse",
|
||||
"ProjectResponse",
|
||||
"SubmitIngestJobRequest",
|
||||
"UploadAssetRequest",
|
||||
"UploadAssetResponse",
|
||||
]
|
||||
|
||||
@@ -2,24 +2,48 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateAssetRequest(BaseModel):
|
||||
workspace_id: str = Field(..., min_length=1)
|
||||
project_id: str = Field(..., min_length=1)
|
||||
library_id: str = Field(..., min_length=1)
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
storage_key: str = Field(..., min_length=1, max_length=255)
|
||||
mime_type: str = Field(..., min_length=1, max_length=100)
|
||||
metadata: dict[str, object] = Field(default_factory=dict)
|
||||
file_size: int = Field(default=0, ge=0)
|
||||
thumbnail_url: str | None = None
|
||||
duration: float | None = Field(default=None, ge=0)
|
||||
width: int | None = Field(default=None, ge=0)
|
||||
height: int | None = Field(default=None, ge=0)
|
||||
fps: float | None = Field(default=None, ge=0)
|
||||
codec: str | None = None
|
||||
status: str = Field(default="uploading")
|
||||
classification_status: str = Field(default="pending")
|
||||
quality_score: float | None = Field(default=None, ge=0, le=100)
|
||||
uploaded_by_user_id: str = Field(default="", max_length=100)
|
||||
|
||||
|
||||
class 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
|
||||
storage_key: str
|
||||
mime_type: str
|
||||
metadata: dict[str, object]
|
||||
file_size: int
|
||||
thumbnail_url: str | None = None
|
||||
duration: float | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
fps: float | None = None
|
||||
codec: str | None = None
|
||||
status: str
|
||||
classification_status: str
|
||||
quality_score: float | None = None
|
||||
uploaded_by_user_id: str
|
||||
|
||||
|
||||
class ListAssetsResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AssetSmartViewItem(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
count: int
|
||||
description: str
|
||||
|
||||
|
||||
class AssetGapItem(BaseModel):
|
||||
key: str
|
||||
severity: str
|
||||
message: str
|
||||
recommendation: str
|
||||
|
||||
|
||||
class ProjectAssetDiagnosisResponse(BaseModel):
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
readiness_score: int
|
||||
readiness_label: str
|
||||
total_assets: int
|
||||
ready_assets: int
|
||||
video_assets: int
|
||||
image_assets: int
|
||||
voice_assets: int
|
||||
total_duration_seconds: float
|
||||
estimated_video_count: int
|
||||
used_assets: int
|
||||
unused_assets: int
|
||||
pending_review_assets: int
|
||||
smart_views: list[AssetSmartViewItem]
|
||||
gaps: list[AssetGapItem]
|
||||
@@ -2,18 +2,18 @@ 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)$")
|
||||
kind: str = Field(..., pattern="^(video|voice|image)$")
|
||||
|
||||
|
||||
class AssetLibraryResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
name: str
|
||||
kind: str
|
||||
asset_count: int
|
||||
total_size: int
|
||||
|
||||
|
||||
class ListAssetLibrariesResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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")
|
||||
@@ -0,0 +1,16 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SubmitClassificationJobRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1)
|
||||
asset_id: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class ClassificationJobResponse(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
asset_id: str
|
||||
status: str
|
||||
classification: str
|
||||
confidence: float
|
||||
error_message: str
|
||||
@@ -0,0 +1,64 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EditTemplateResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
name: str
|
||||
description: str
|
||||
target_duration: float
|
||||
clip_count: int
|
||||
is_active: bool
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class EditPlanClipResponse(BaseModel):
|
||||
id: str
|
||||
asset_id: str
|
||||
asset_name: str
|
||||
sequence: int
|
||||
start_time: float
|
||||
duration: float
|
||||
reason: str
|
||||
layer: str = "main" # main, pip, broll
|
||||
|
||||
|
||||
class EditPlanResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
template_id: str
|
||||
asset_library_id: str
|
||||
title_id: str = ""
|
||||
status: str
|
||||
summary: str
|
||||
editing_mode: str | None = None # one_take, pip, voice_over, voice_pip
|
||||
clips: list[EditPlanClipResponse] = Field(default_factory=list)
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class CreateEditPlanRequest(BaseModel):
|
||||
workspace_id: str
|
||||
asset_library_id: str
|
||||
template_id: str = ""
|
||||
title_id: str = ""
|
||||
|
||||
|
||||
class AutoGenerateEditPlanRequest(BaseModel):
|
||||
"""智能生成剪辑计划请求"""
|
||||
workspace_id: str
|
||||
asset_library_id: str
|
||||
editing_mode: str = Field(
|
||||
default="one_take",
|
||||
description="剪辑模式: one_take, pip, voice_over, voice_pip"
|
||||
)
|
||||
target_duration: float = Field(
|
||||
default=30.0,
|
||||
description="目标时长(秒)"
|
||||
)
|
||||
template_id: str = ""
|
||||
title_id: str = ""
|
||||
@@ -0,0 +1,36 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
GeneratedVideoReviewStatus = Literal["pending_review", "approved", "rejected"]
|
||||
|
||||
|
||||
class UpdateGeneratedVideoReviewRequest(BaseModel):
|
||||
review_status: GeneratedVideoReviewStatus
|
||||
|
||||
|
||||
class GeneratedVideoResponse(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
generation_task_id: str
|
||||
name: str
|
||||
file_url: str
|
||||
file_size: int
|
||||
duration: float
|
||||
thumbnail_url: str | None = None
|
||||
width: int
|
||||
height: int
|
||||
fps: float
|
||||
status: str = "completed"
|
||||
review_status: str = "pending_review"
|
||||
generation_params: dict = Field(default_factory=dict)
|
||||
download_url: str | None = None
|
||||
|
||||
|
||||
class GeneratedVideoDownloadUrlResponse(BaseModel):
|
||||
video_id: str
|
||||
download_url: str
|
||||
|
||||
|
||||
class ListGeneratedVideosResponse(BaseModel):
|
||||
items: list[GeneratedVideoResponse]
|
||||
@@ -0,0 +1,23 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateGenerationTaskRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1)
|
||||
asset_library_id: str = Field(..., min_length=1)
|
||||
strategy_id: str = ""
|
||||
voice_library_id: str = ""
|
||||
edit_plan_id: str = ""
|
||||
created_by_user_id: str = ""
|
||||
|
||||
|
||||
class GenerationTaskResponse(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
asset_library_id: str
|
||||
strategy_id: str
|
||||
voice_library_id: str
|
||||
edit_plan_id: str
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
error_message: str
|
||||
@@ -2,7 +2,6 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SubmitIngestJobRequest(BaseModel):
|
||||
workspace_id: str = Field(..., min_length=1)
|
||||
project_id: str = Field(..., min_length=1)
|
||||
library_id: str = Field(..., min_length=1)
|
||||
storage_key: str = Field(..., min_length=1, max_length=255)
|
||||
@@ -10,7 +9,6 @@ class SubmitIngestJobRequest(BaseModel):
|
||||
|
||||
class IngestJobResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
library_id: str
|
||||
storage_key: str
|
||||
|
||||
@@ -2,16 +2,16 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateProjectRequest(BaseModel):
|
||||
workspace_id: str = Field(..., min_length=1)
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: str = Field(default="", max_length=500)
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
owner_user_id: str
|
||||
name: str
|
||||
description: str
|
||||
shared_users: list[str] = []
|
||||
|
||||
|
||||
class ListProjectsResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
TitleCategory = Literal["default", "marketing", "tutorial", "story", "promo"]
|
||||
|
||||
|
||||
class ProjectTitleResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
text: str
|
||||
category: str
|
||||
favorite: bool
|
||||
usage_count: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ListProjectTitlesResponse(BaseModel):
|
||||
items: list[ProjectTitleResponse]
|
||||
|
||||
|
||||
class CreateProjectTitleRequest(BaseModel):
|
||||
workspace_id: str
|
||||
text: str = Field(min_length=1, max_length=200)
|
||||
category: TitleCategory = "default"
|
||||
favorite: bool = False
|
||||
|
||||
|
||||
class UpdateProjectTitleRequest(BaseModel):
|
||||
text: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
category: TitleCategory | None = None
|
||||
favorite: bool | None = None
|
||||
is_active: bool | None = None
|
||||
@@ -0,0 +1,23 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ProjectTaskResponse(BaseModel):
|
||||
id: str
|
||||
task_type: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
status: str
|
||||
progress: float
|
||||
current_step: str
|
||||
error_message: str = ""
|
||||
user_message: str = ""
|
||||
retryable: bool = False
|
||||
source_id: str = ""
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class ListProjectTasksResponse(BaseModel):
|
||||
items: list[ProjectTaskResponse] = Field(default_factory=list)
|
||||
@@ -5,3 +5,31 @@ class UploadAssetResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
url: str = Field(..., description="Public URL of uploaded file")
|
||||
|
||||
|
||||
class DirectUploadPrepareRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1)
|
||||
library_id: str = Field(..., min_length=1)
|
||||
filename: str = Field(..., min_length=1, max_length=255)
|
||||
content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100)
|
||||
file_size: int = Field(..., gt=0)
|
||||
|
||||
|
||||
class DirectUploadPrepareResponse(BaseModel):
|
||||
upload_url: str
|
||||
method: str
|
||||
storage_key: str
|
||||
expires_at: str
|
||||
fields: dict[str, str]
|
||||
max_size_bytes: int
|
||||
|
||||
|
||||
class DirectUploadCompleteRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1)
|
||||
library_id: str = Field(..., min_length=1)
|
||||
storage_key: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
|
||||
class DirectUploadCompleteResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
|
||||
+76
-60
@@ -1,67 +1,83 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import os
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.api.router import api_router, health_router
|
||||
from app.config import settings
|
||||
from app.middleware.exceptions import (
|
||||
APIException,
|
||||
api_exception_handler,
|
||||
general_exception_handler,
|
||||
http_exception_handler,
|
||||
validation_exception_handler,
|
||||
)
|
||||
from app.middleware.logging import RequestLoggingMiddleware
|
||||
from fastapi import FastAPI
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
app = FastAPI(
|
||||
title="小虾 SaaS API",
|
||||
description="自动化剪辑 SaaS 平台 API",
|
||||
version=settings.APP_VERSION,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
app.add_exception_handler(APIException, api_exception_handler)
|
||||
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
|
||||
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||
app.add_exception_handler(Exception, general_exception_handler)
|
||||
|
||||
# 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 = [origin for origin in settings.CORS_ORIGINS if origin != "*"]
|
||||
if not allow_origins:
|
||||
# Default to production domain if no valid origins configured
|
||||
allow_origins = ["https://xiaoxiajianji.com"]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=allow_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(api_router)
|
||||
|
||||
_generated_files_dir = os.getenv("GENERATED_FILES_DIR", "/app/generated")
|
||||
os.makedirs(_generated_files_dir, exist_ok=True)
|
||||
app.mount(
|
||||
os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files"),
|
||||
StaticFiles(directory=_generated_files_dir),
|
||||
name="generated-files",
|
||||
)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="小虾 SaaS API",
|
||||
description="""
|
||||
小虾 SaaS 自动化剪辑系统 API
|
||||
|
||||
## 功能模块
|
||||
|
||||
### 📁 资源库管理
|
||||
- **Projects**: 项目管理
|
||||
- **Asset Libraries**: 资产库管理
|
||||
- **Assets**: 素材资产管理
|
||||
|
||||
### 📤 素材导入
|
||||
- **Upload**: 文件上传(支持 MinIO 对象存储)
|
||||
- **Ingest Jobs**: 素材导入任务管理
|
||||
|
||||
### 🎬 自动化剪辑
|
||||
- 智能场景分割
|
||||
- 自动转场
|
||||
- 字幕生成
|
||||
|
||||
## 技术栈
|
||||
- FastAPI + Python 3.12
|
||||
- PostgreSQL 数据库
|
||||
- Redis 队列
|
||||
- Celery 异步任务
|
||||
- MinIO 对象存储
|
||||
""",
|
||||
version="0.1.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
contact={
|
||||
"name": "小虾团队",
|
||||
"email": "dev@xiaoxiajianji.com",
|
||||
},
|
||||
license_info={
|
||||
"name": "Proprietary",
|
||||
},
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # TODO: Configure for production
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include API routes
|
||||
app.include_router(api_router, prefix=settings.api_prefix)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"service": settings.APP_NAME,
|
||||
"status": "running",
|
||||
"version": settings.APP_VERSION,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host=settings.API_HOST, port=settings.API_PORT)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* ESLint 配置
|
||||
*/
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
}
|
||||
+113
-9
@@ -1,14 +1,118 @@
|
||||
# xiaoxia-saas Web
|
||||
# 小虾 SaaS - 前端应用
|
||||
|
||||
Next.js frontend app placeholder.
|
||||
基于 Vite + React 18 + TypeScript + Ant Design 的现代化 SaaS 前端应用。
|
||||
|
||||
## Planned stack
|
||||
## 快速开始
|
||||
|
||||
- Next.js
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
- shadcn/ui
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
## Current status
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
|
||||
Scaffold phase only. Runtime initialization will be added after final stack bootstrap.
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
|
||||
# 预览生产版本
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架:** React 18.3.1
|
||||
- **语言:** TypeScript 5.5.3
|
||||
- **构建工具:** Vite 5.3.1
|
||||
- **UI 组件库:** Ant Design 5.18.0
|
||||
- **路由:** React Router 6.24.0
|
||||
- **状态管理:** Zustand 4.5.2
|
||||
- **数据获取:** React Query 5.45.0
|
||||
- **HTTP 客户端:** Axios 1.7.2
|
||||
- **表单:** React Hook Form 7.52.0 + Zod 3.23.8
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API 服务层
|
||||
├── components/ # React 组件
|
||||
│ ├── common/ # 通用组件
|
||||
│ ├── layout/ # 布局组件
|
||||
│ └── business/ # 业务组件
|
||||
├── pages/ # 页面组件
|
||||
│ ├── auth/ # 认证页面
|
||||
│ ├── workspace/ # 工作空间
|
||||
│ ├── subscription/# 订阅管理
|
||||
│ ├── admin/ # Admin 后台
|
||||
│ └── profile/ # 个人中心
|
||||
├── hooks/ # 自定义 Hooks
|
||||
├── store/ # 状态管理
|
||||
├── router/ # 路由配置
|
||||
├── types/ # TypeScript 类型
|
||||
├── utils/ # 工具函数
|
||||
└── styles/ # 全局样式
|
||||
```
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 用户认证(登录/注册/密码重置)
|
||||
- ✅ 工作空间管理
|
||||
- ✅ 成员管理和权限控制
|
||||
- ✅ 订阅计划和升级
|
||||
- ✅ 配额使用监控
|
||||
- ✅ Admin 管理后台
|
||||
- ✅ 个人设置和安全
|
||||
- ✅ 响应式设计
|
||||
|
||||
## 环境变量
|
||||
|
||||
创建 `.env` 文件:
|
||||
|
||||
```env
|
||||
VITE_API_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 添加新页面
|
||||
|
||||
1. 在 `src/pages/` 下创建页面组件
|
||||
2. 在 `src/router/index.tsx` 中添加路由
|
||||
3. 确保导出 `Component` 用于懒加载
|
||||
|
||||
### 添加新 API
|
||||
|
||||
1. 在 `src/api/` 下创建服务模块
|
||||
2. 定义 TypeScript 接口
|
||||
3. 使用 `apiClient` 发起请求
|
||||
|
||||
### 状态管理
|
||||
|
||||
使用 Zustand 创建 Store:
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface MyStore {
|
||||
data: any;
|
||||
setData: (data: any) => void;
|
||||
}
|
||||
|
||||
export const useMyStore = create<MyStore>((set) => ({
|
||||
data: null,
|
||||
setData: (data) => set({ data }),
|
||||
}));
|
||||
```
|
||||
|
||||
## 部署
|
||||
|
||||
```bash
|
||||
# 构建
|
||||
npm run build
|
||||
|
||||
# 产物在 dist/ 目录
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
interface CreateIssueFormProps {
|
||||
taskId: string;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function CreateIssueForm({ taskId, projectId, workspaceId, onSuccess, onCancel }: CreateIssueFormProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-management/issues`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
task_id: taskId,
|
||||
project_id: projectId,
|
||||
workspace_id: workspaceId,
|
||||
...formData,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.detail || '创建失败');
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{
|
||||
background: 'var(--bg-white)',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
}}>
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#FFECE8',
|
||||
border: '1px solid var(--error)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--error)',
|
||||
marginBottom: '16px',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500' }}>
|
||||
问题标题 <span style={{ color: 'var(--error)' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="简要描述问题"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500' }}>
|
||||
详细描述
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
placeholder="详细说明问题情况"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: loading ? '#ccc' : 'var(--primary)',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{loading ? '创建中...' : '创建问题'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
interface CreateTaskFormProps {
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function CreateTaskForm({ projectId, workspaceId, onSuccess, onCancel }: CreateTaskFormProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
assignee_user_id: '',
|
||||
parent_task_id: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-management/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project_id: projectId,
|
||||
workspace_id: workspaceId,
|
||||
...formData,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.detail || '创建失败');
|
||||
}
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
} else {
|
||||
router.push('/projects');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{
|
||||
background: 'var(--bg-white)',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
}}>
|
||||
<h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: 'bold' }}>新增任务</h2>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#FFECE8',
|
||||
border: '1px solid var(--error)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--error)',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
|
||||
任务名称 <span style={{ color: 'var(--error)' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="输入任务名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
|
||||
任务描述
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={4}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
placeholder="详细描述任务内容"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
|
||||
优先级
|
||||
</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="low">低</option>
|
||||
<option value="medium">中</option>
|
||||
<option value="high">高</option>
|
||||
<option value="urgent">紧急</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
|
||||
负责人 ID(可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.assignee_user_id}
|
||||
onChange={(e) => setFormData({ ...formData, assignee_user_id: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="输入负责人 ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: loading ? '#ccc' : 'var(--primary)',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{loading ? '创建中...' : '创建任务'}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
interface EditTaskFormProps {
|
||||
taskId: string;
|
||||
initialData: {
|
||||
name: string;
|
||||
description: string;
|
||||
priority: string;
|
||||
assignee_user_id: string;
|
||||
};
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function EditTaskForm({ taskId, initialData, onSuccess, onCancel }: EditTaskFormProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [formData, setFormData] = useState(initialData);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-management/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.detail || '保存失败');
|
||||
}
|
||||
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{
|
||||
background: 'var(--bg-white)',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
}}>
|
||||
<h3 style={{ marginBottom: '20px', fontSize: '18px', fontWeight: 'bold' }}>编辑任务</h3>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#FFECE8',
|
||||
border: '1px solid var(--error)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--error)',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
|
||||
任务名称 <span style={{ color: 'var(--error)' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
|
||||
任务描述
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={4}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
|
||||
优先级
|
||||
</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="low">低</option>
|
||||
<option value="medium">中</option>
|
||||
<option value="high">高</option>
|
||||
<option value="urgent">紧急</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: loading ? '#ccc' : 'var(--primary)',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{loading ? '保存中...' : '保存修改'}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Microsoft YaHei", sans-serif;
|
||||
background: #F5F7FA;
|
||||
color: #1D2129;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary: #165DFF;
|
||||
--primary-hover: #0e48d1;
|
||||
--border: #E5E6EB;
|
||||
--bg-white: #fff;
|
||||
--bg-gray: #F5F7FA;
|
||||
--text-primary: #1D2129;
|
||||
--text-secondary: #6E7681;
|
||||
--success: #00B42A;
|
||||
--warning: #FF7D00;
|
||||
--error: #F53F3F;
|
||||
}
|
||||
+11
-4
@@ -1,9 +1,16 @@
|
||||
export const metadata = {
|
||||
title: 'xiaoxia-saas',
|
||||
description: 'Xiaoxia SaaS scaffold',
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "小虾 SaaS - 项目推进器",
|
||||
description: "AI 视频自动化剪辑系统 - 项目管理",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Milestone {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
target_date: string | null;
|
||||
completed: boolean;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
export default function MilestonesPage() {
|
||||
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [formData, setFormData] = useState({ name: '', description: '' });
|
||||
|
||||
const projectId = 'demo_project_1';
|
||||
const workspaceId = 'demo_workspace_1';
|
||||
|
||||
useEffect(() => {
|
||||
fetchMilestones();
|
||||
}, []);
|
||||
|
||||
const fetchMilestones = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-management/milestones?project_id=${projectId}`);
|
||||
if (!res.ok) throw new Error('获取里程碑列表失败');
|
||||
const data = await res.json();
|
||||
setMilestones(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateMilestone = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-management/milestones`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project_id: projectId,
|
||||
workspace_id: workspaceId,
|
||||
...formData,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error('创建失败');
|
||||
setFormData({ name: '', description: '' });
|
||||
setShowCreateForm(false);
|
||||
fetchMilestones();
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
||||
{/* Header */}
|
||||
<header style={{
|
||||
height: '60px',
|
||||
background: 'var(--bg-white)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 20px',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
|
||||
<Link href="/" style={{ fontSize: '18px', fontWeight: 'bold', color: 'var(--primary)', textDecoration: 'none' }}>
|
||||
📁 项目推进器
|
||||
</Link>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>里程碑管理</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: 'var(--primary)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{showCreateForm ? '取消' : '+ 新增里程碑'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Main */}
|
||||
<main style={{ flex: 1, padding: '20px', overflow: 'auto' }}>
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#FFECE8',
|
||||
border: '1px solid var(--error)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--error)',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreateForm && (
|
||||
<div style={{ background: 'var(--bg-white)', padding: '24px', borderRadius: '8px', marginBottom: '20px' }}>
|
||||
<h3 style={{ marginBottom: '16px', fontSize: '18px', fontWeight: 'bold' }}>新增里程碑</h3>
|
||||
<form onSubmit={handleCreateMilestone}>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500' }}>里程碑名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
placeholder="例如:V1.0 发布"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500' }}>描述</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
placeholder="详细说明里程碑内容"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: 'var(--primary)',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
创建里程碑
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{milestones.length === 0 ? (
|
||||
<div style={{
|
||||
background: 'var(--bg-white)',
|
||||
borderRadius: '8px',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-secondary)',
|
||||
}}>
|
||||
<p>暂无里程碑</p>
|
||||
<p style={{ fontSize: '14px', marginTop: '8px' }}>点击右上角"+ 新增里程碑"创建第一个里程碑</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
{milestones.map((milestone) => (
|
||||
<div
|
||||
key={milestone.id}
|
||||
style={{
|
||||
background: 'var(--bg-white)',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
border: `2px solid ${milestone.completed ? 'var(--success)' : 'var(--border)'}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '24px' }}>{milestone.completed ? '🎉' : '🎯'}</span>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 'bold', flex: 1 }}>{milestone.name}</h3>
|
||||
<span style={{
|
||||
padding: '4px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
background: milestone.completed ? 'var(--success)' : '#E5E6EB',
|
||||
color: milestone.completed ? 'white' : 'var(--text-secondary)',
|
||||
}}>
|
||||
{milestone.completed ? '已完成' : '进行中'}
|
||||
</span>
|
||||
</div>
|
||||
{milestone.description && (
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: '1.6', marginBottom: '12px' }}>
|
||||
{milestone.description}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
|
||||
创建时间:{new Date(milestone.created_at).toLocaleDateString('zh-CN')}
|
||||
{milestone.completed_at && (
|
||||
<span style={{ marginLeft: '12px' }}>
|
||||
完成时间:{new Date(milestone.completed_at).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+58
-10
@@ -1,14 +1,62 @@
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main style={{ padding: 32, fontFamily: 'sans-serif' }}>
|
||||
<h1>xiaoxia-saas</h1>
|
||||
<p>SaaS 新主线前端骨架已创建。</p>
|
||||
<ul>
|
||||
<li>项目管理</li>
|
||||
<li>素材管理</li>
|
||||
<li>配音管理</li>
|
||||
<li>任务与成片</li>
|
||||
</ul>
|
||||
</main>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '20px'
|
||||
}}>
|
||||
<h1 style={{ fontSize: '32px', fontWeight: 'bold', color: 'var(--primary)' }}>
|
||||
📁 小虾 SaaS 项目推进器
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>
|
||||
完整的项目管理与任务跟踪系统
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<a
|
||||
href="/projects"
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: 'var(--primary)',
|
||||
color: 'white',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
进入项目列表
|
||||
</a>
|
||||
<a
|
||||
href="/milestones"
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: 'white',
|
||||
color: 'var(--primary)',
|
||||
border: '1px solid var(--primary)',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
里程碑管理
|
||||
</a>
|
||||
<a
|
||||
href="/api/docs"
|
||||
target="_blank"
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: 'white',
|
||||
color: 'var(--primary)',
|
||||
border: '1px solid var(--primary)',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
>
|
||||
查看 API 文档
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import CreateTaskForm from '../components/CreateTaskForm';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
progress: number;
|
||||
assignee_user_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
// 模拟项目ID,生产环境应该从路由或上下文获取
|
||||
const projectId = 'demo_project_1';
|
||||
const workspaceId = 'demo_workspace_1';
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, []);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/project-management/tasks?project_id=${projectId}`);
|
||||
if (!res.ok) throw new Error('获取任务列表失败');
|
||||
const data = await res.json();
|
||||
setTasks(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
pending: '#86909C',
|
||||
in_progress: '#165DFF',
|
||||
completed: '#00B42A',
|
||||
blocked: '#F53F3F',
|
||||
cancelled: '#6E7681',
|
||||
};
|
||||
return colors[status] || '#6E7681';
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
pending: '待开始',
|
||||
in_progress: '进行中',
|
||||
completed: '已完成',
|
||||
blocked: '阻塞',
|
||||
cancelled: '已取消',
|
||||
};
|
||||
return texts[status] || status;
|
||||
};
|
||||
|
||||
const getPriorityText = (priority: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
urgent: '紧急',
|
||||
};
|
||||
return texts[priority] || priority;
|
||||
};
|
||||
|
||||
if (loading && !showCreateForm) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>加载中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
||||
{/* Header */}
|
||||
<header style={{
|
||||
height: '60px',
|
||||
background: 'var(--bg-white)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 20px',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
|
||||
<Link href="/" style={{ fontSize: '18px', fontWeight: 'bold', color: 'var(--primary)', textDecoration: 'none' }}>
|
||||
📁 项目推进器
|
||||
</Link>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Demo 项目</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button
|
||||
onClick={fetchTasks}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-white)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: 'var(--primary)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{showCreateForm ? '取消' : '+ 新增任务'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main style={{ flex: 1, padding: '20px', overflow: 'auto' }}>
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#FFECE8',
|
||||
border: '1px solid #F53F3F',
|
||||
borderRadius: '4px',
|
||||
color: '#F53F3F',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreateForm ? (
|
||||
<CreateTaskForm
|
||||
projectId={projectId}
|
||||
workspaceId={workspaceId}
|
||||
onSuccess={() => {
|
||||
setShowCreateForm(false);
|
||||
fetchTasks();
|
||||
}}
|
||||
onCancel={() => setShowCreateForm(false)}
|
||||
/>
|
||||
) : tasks.length === 0 ? (
|
||||
<div style={{
|
||||
background: 'var(--bg-white)',
|
||||
borderRadius: '8px',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-secondary)',
|
||||
}}>
|
||||
<p>暂无任务</p>
|
||||
<p style={{ fontSize: '14px', marginTop: '8px' }}>点击右上角"+ 新增任务"创建第一个任务</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: 'var(--bg-white)',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', color: 'var(--text-secondary)', fontWeight: 'normal' }}>任务名称</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', color: 'var(--text-secondary)', fontWeight: 'normal' }}>状态</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', color: 'var(--text-secondary)', fontWeight: 'normal' }}>优先级</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', color: 'var(--text-secondary)', fontWeight: 'normal' }}>进度</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', color: 'var(--text-secondary)', fontWeight: 'normal' }}>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task) => (
|
||||
<tr key={task.id} style={{ borderBottom: '1px solid #F7F8FA' }}>
|
||||
<td style={{ padding: '12px', fontWeight: '500' }}>
|
||||
<Link href={`/tasks/${task.id}`} style={{ color: 'var(--primary)', textDecoration: 'none' }}>
|
||||
{task.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
color: 'white',
|
||||
background: getStatusColor(task.status),
|
||||
}}>
|
||||
{getStatusText(task.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', color: 'var(--text-secondary)' }}>
|
||||
{getPriorityText(task.priority)}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
height: '6px',
|
||||
background: '#E5E6EB',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${task.progress}%`,
|
||||
height: '100%',
|
||||
background: 'var(--primary)',
|
||||
transition: 'width 0.3s',
|
||||
}} />
|
||||
</div>
|
||||
<span style={{ fontSize: '12px', color: 'var(--text-secondary)', minWidth: '40px' }}>
|
||||
{task.progress}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '12px', color: 'var(--text-secondary)' }}>
|
||||
{new Date(task.created_at).toLocaleDateString('zh-CN')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer style={{
|
||||
height: '30px',
|
||||
lineHeight: '30px',
|
||||
background: 'var(--bg-white)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: '0 20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-secondary)',
|
||||
}}>
|
||||
<div>当前项目:Demo 项目</div>
|
||||
<div>总计任务:{tasks.length}</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user