Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea65033b01 | |||
| ca8b945054 | |||
| 9e9725015e | |||
| 164246c0b0 | |||
| d6549ebf40 | |||
| e2bc857e05 | |||
| fb2785f037 | |||
| 03c789c212 | |||
| 7d3328f462 | |||
| c24f24258a | |||
| f2cea1831e | |||
| 6cbd08f666 | |||
| ede42dd3c4 | |||
| b797e88b26 | |||
| 62ab361940 | |||
| ed3ab34028 | |||
| df016b18fb | |||
| c6aac862b1 | |||
| 6232300fb1 | |||
| fe2ab121e7 | |||
| 84db18a959 | |||
| 9501afe9b3 | |||
| 7407ec2957 | |||
| e45426fe7d | |||
| c540d88306 | |||
| 5fb49480e5 | |||
| d73fb2cf2b | |||
| 0eb1262db6 | |||
| 9a6136c9fd | |||
| 63f138e2d6 | |||
| d898856f50 | |||
| acffa364b3 | |||
| 1931cab504 | |||
| 9f9fd1ccb8 | |||
| 9fd12c7fb3 | |||
| 4805e961f0 | |||
| 1aeeb23621 | |||
| 4a21b89a0b | |||
| 2982ef5259 | |||
| 5fff7e518d | |||
| a93149b8e5 | |||
| 21aaf642ec | |||
| 8d86707e2e | |||
| 752de50557 | |||
| ba6c5f2598 | |||
| 78d1c88ca1 | |||
| c88be032c1 | |||
| 7f767e2dd1 | |||
| ea93387f98 | |||
| 7e172c0907 | |||
| 6db705a05d | |||
| 1c8cb20373 | |||
| 74c458e370 | |||
| a7d942f705 | |||
| f867897348 | |||
| 3d1b739e7f | |||
| f268e208de | |||
| b05966ff48 | |||
| 79b82978d8 | |||
| 08b51ffa1d | |||
| 23d2406c27 | |||
| def6ee2363 | |||
| 2b5b650b9e | |||
| 2b8326987e | |||
| 2081c72be6 | |||
| a681844a44 | |||
| 8a2d2df3cd | |||
| ff38ee0f2b | |||
| 368baf683b | |||
| 527eb61f19 | |||
| e9a6d19e00 | |||
| 241760ef39 | |||
| 3cd26e98db | |||
| c840f37a44 | |||
| edcd1a926f | |||
| 9ddaaf7f00 | |||
| 94aead4342 | |||
| 295d7f0765 | |||
| 7cffb193eb | |||
| e430d83f78 | |||
| f4b4f1fc4f | |||
| 58ff565c48 | |||
| eb4645314d | |||
| 17fbae13a8 | |||
| 41e421b44b | |||
| 9f86bd40ca | |||
| 881eea9195 | |||
| 4faceb8093 | |||
| 37293a665d | |||
| 006fd075d6 | |||
| a695342b36 | |||
| 6f0a8253f6 | |||
| b562e152e0 | |||
| 6fc1abf2f5 | |||
| 20f6e64847 | |||
| 6897a4be96 | |||
| 6d2d5abff2 | |||
| e3f5ab5611 | |||
| d061bccdd2 | |||
| a3967c6829 | |||
| 97a81a39f3 | |||
| eab471703e | |||
| 32a53ab9e6 | |||
| e50ba67c11 | |||
| e8f9e2dabe | |||
| 3f773795ed | |||
| 29ed5df884 | |||
| 188e535af8 | |||
| 6227aa610c | |||
| d90bc6bbfc | |||
| 3ebcc7e066 | |||
| 19d5dcbc5a | |||
| 1a57878f76 | |||
| 923c6bad1c | |||
| f0dee5bbd3 | |||
| 50719db7c8 | |||
| c36ec5e780 | |||
| eb50442296 | |||
| 74a136e931 | |||
| 73375c6639 | |||
| 5bde975ea6 | |||
| 0634fc4833 | |||
| 0000c30ef2 | |||
| cc47c9f90f | |||
| c1e466f9c1 | |||
| 8598638e8f | |||
| c48ddeef7d | |||
| b87d7b763e | |||
| 9c6c477f55 | |||
| c2ebe9d254 | |||
| 1d06d2ddd2 | |||
| 5e704094f6 | |||
| ffd99ffeb0 | |||
| 1b2bccee6f | |||
| a0cac1b75d | |||
| bbe831f9e0 | |||
| 4c5ab7f80e | |||
| 9b2e782abd | |||
| 788559ff29 | |||
| bdf99bba39 | |||
| bfe8bfe2da | |||
| fdcf48103e | |||
| 9e87ac05c6 | |||
| 8427bb6852 | |||
| ad86f5bc79 | |||
| e39f8bacdd | |||
| 8883581e34 | |||
| c80a6935fd | |||
| a6ae041944 | |||
| 082c9f6f09 | |||
| da4b95a40a | |||
| 7c541910b3 | |||
| e5d627fc3e |
@@ -0,0 +1 @@
|
|||||||
|
re-trigger
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
# CI trigger Fri Jun 26 09:53:28 PM CST 2026
|
trigger: 1784009947
|
||||||
|
|||||||
+4
-1
@@ -3,6 +3,7 @@
|
|||||||
# ==================== 应用配置 ====================
|
# ==================== 应用配置 ====================
|
||||||
APP_NAME=小虾 SaaS
|
APP_NAME=小虾 SaaS
|
||||||
APP_BASE_URL=http://localhost:3000
|
APP_BASE_URL=http://localhost:3000
|
||||||
|
APP_ENV=development
|
||||||
|
|
||||||
# ==================== 数据库配置 ====================
|
# ==================== 数据库配置 ====================
|
||||||
DATABASE_URL=postgresql://xiaoxia_user:your_password@localhost:5432/xiaoxia_saas
|
DATABASE_URL=postgresql://xiaoxia_user:your_password@localhost:5432/xiaoxia_saas
|
||||||
@@ -35,7 +36,8 @@ ENVIRONMENT=development
|
|||||||
DEBUG=true
|
DEBUG=true
|
||||||
|
|
||||||
# ==================== CORS 配置 ====================
|
# ==================== CORS 配置 ====================
|
||||||
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
|
# 逗号分隔的域名列表(Settings 读取 CORS_ORIGINS_RAW)
|
||||||
|
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173
|
||||||
|
|
||||||
# ==================== 阿里云 OSS 配置 ====================
|
# ==================== 阿里云 OSS 配置 ====================
|
||||||
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
||||||
@@ -49,6 +51,7 @@ OSS_BUCKET_NAME=xiaoxia-autocut
|
|||||||
# cosyvoice-v3-plus (高质量,系统音色少)
|
# cosyvoice-v3-plus (高质量,系统音色少)
|
||||||
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus (仅支持克隆/设计音色,无系统音色)
|
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus (仅支持克隆/设计音色,无系统音色)
|
||||||
# 音色: v3系列系统音色带 _v3 后缀,如 longxiaochun_v3, longxiaoxia_v3, longanyang (无后缀)
|
# 音色: v3系列系统音色带 _v3 后缀,如 longxiaochun_v3, longxiaoxia_v3, longanyang (无后缀)
|
||||||
|
# 注意:COSYVOICE_* 变量由 packages/shared/config.py 的 SharedSettings 读取
|
||||||
COSYVOICE_API_KEY=your-cosyvoice-api-key
|
COSYVOICE_API_KEY=your-cosyvoice-api-key
|
||||||
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||||
COSYVOICE_MODEL=cosyvoice-v3-flash
|
COSYVOICE_MODEL=cosyvoice-v3-flash
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ APP_ENV=production
|
|||||||
ENVIRONMENT=production
|
ENVIRONMENT=production
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
USE_IN_MEMORY_DB=false
|
USE_IN_MEMORY_DB=false
|
||||||
LOG_LEVEL=WARNING
|
|
||||||
|
|
||||||
# ==================== 数据库(必须修改)====================
|
# ==================== 数据库(必须修改)====================
|
||||||
DATABASE_URL=postgresql://prod_user:CHANGE_THIS_PASSWORD@db-prod:5432/xiaoxia_prod
|
DATABASE_URL=postgresql://prod_user:CHANGE_THIS_PASSWORD@db-prod:5432/xiaoxia_prod
|
||||||
|
|||||||
Executable → Regular
+1037
-920
File diff suppressed because one or more lines are too long
@@ -1,44 +0,0 @@
|
|||||||
name: Debug CMD Agent
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'debug/cmd-agent'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
debug:
|
|
||||||
name: Debug CMD Agent
|
|
||||||
runs-on: host
|
|
||||||
timeout-minutes: 5
|
|
||||||
steps:
|
|
||||||
- name: Diagnose
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
echo "=== 1. CMD Agent config ==="
|
|
||||||
cat /opt/xiaoxia-cmd-agent/config.json 2>/dev/null || cat /opt/xiaoxia-cmd-agent/config.yaml 2>/dev/null || echo "no config found"
|
|
||||||
ls -la /opt/xiaoxia-cmd-agent/ 2>/dev/null
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== 2. CMD Agent process ==="
|
|
||||||
ps aux | grep cmd-agent | grep -v grep
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== 3. Local curl test (127.0.0.1:18888) ==="
|
|
||||||
curl -s -X POST http://127.0.0.1:18888/cmd-agent/exec \
|
|
||||||
-H "Authorization: Bearer xsa-f2778a6953d59948cd1e5be4d99f60f7" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"command":"hostname"}' 2>&1 || echo "FAILED"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== 4. Nginx config for cmd-agent ==="
|
|
||||||
grep -r "cmd-agent" /etc/nginx/sites-enabled/ 2>/dev/null || \
|
|
||||||
grep -r "cmd-agent" /etc/nginx/conf.d/ 2>/dev/null || \
|
|
||||||
echo "no nginx cmd-agent config found"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== 5. Nginx access log (last 5 lines) ==="
|
|
||||||
tail -5 /var/log/nginx/access.log 2>/dev/null | grep cmd || echo "no log"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== DONE ==="
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
name: Fix CMD Agent Auth
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'debug/cmd-agent'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
fix:
|
|
||||||
runs-on: host
|
|
||||||
steps:
|
|
||||||
- name: 验证不带Bearer
|
|
||||||
run: |
|
|
||||||
curl -s -w "\nHTTP_CODE:%{http_code}" http://127.0.0.1:18888/status -H "Authorization: xsa-f2778a6953d59948cd1e5be4d99f60f7"
|
|
||||||
- name: 验证带Bearer(应该失败)
|
|
||||||
run: |
|
|
||||||
curl -s -w "\nHTTP_CODE:%{http_code}" http://127.0.0.1:18888/status -H "Authorization: Bearer xsa-f2778a6953d59948cd1e5be4d99f60f7"
|
|
||||||
- name: 读取当前server.py的check_auth
|
|
||||||
run: |
|
|
||||||
grep -A 5 "def check_auth" /opt/xiaoxia-cmd-agent/server.py
|
|
||||||
- name: 修复check_auth函数
|
|
||||||
run: |
|
|
||||||
cp /opt/xiaoxia-cmd-agent/server.py /opt/xiaoxia-cmd-agent/server.py.bak
|
|
||||||
sed -i '/def check_auth/,/return True/{
|
|
||||||
/def check_auth/a\ t = self.headers.get("Authorization", "")
|
|
||||||
/if t != AUTH_TOKEN/i\ if t.startswith("Bearer "):\n t = t[7:]
|
|
||||||
}' /opt/xiaoxia-cmd-agent/server.py
|
|
||||||
echo "Done via sed"
|
|
||||||
- name: 验证修复后的check_auth
|
|
||||||
run: |
|
|
||||||
grep -A 8 "def check_auth" /opt/xiaoxia-cmd-agent/server.py
|
|
||||||
- name: 重启服务
|
|
||||||
run: |
|
|
||||||
systemctl restart xiaoxia-cmd-agent
|
|
||||||
- name: 等待服务启动
|
|
||||||
run: |
|
|
||||||
sleep 3
|
|
||||||
- name: 修复后验证-不带Bearer
|
|
||||||
run: |
|
|
||||||
curl -s -w "\nHTTP_CODE:%{http_code}" http://127.0.0.1:18888/status -H "Authorization: xsa-f2778a6953d59948cd1e5be4d99f60f7"
|
|
||||||
- name: 修复后验证-带Bearer
|
|
||||||
run: |
|
|
||||||
curl -s -w "\nHTTP_CODE:%{http_code}" http://127.0.0.1:18888/status -H "Authorization: Bearer xsa-f2778a6953d59948cd1e5be4d99f60f7"
|
|
||||||
- name: 公网路径验证
|
|
||||||
run: |
|
|
||||||
curl -sk -w "\nHTTP_CODE:%{http_code}" https://127.0.0.1/cmd-agent/status -H "Authorization: Bearer xsa-f2778a6953d59948cd1e5be4d99f60f7"
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
name: Read Auth Logic
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'debug/cmd-agent'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
read:
|
|
||||||
name: Read check_auth logic
|
|
||||||
runs-on: host
|
|
||||||
timeout-minutes: 3
|
|
||||||
steps:
|
|
||||||
- name: Read
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "=== Full server.py (lines 1-50) ==="
|
|
||||||
sed -n '1,50p' /opt/xiaoxia-cmd-agent/server.py
|
|
||||||
echo ""
|
|
||||||
echo "=== Lines 120-160 (startup logic) ==="
|
|
||||||
sed -n '120,160p' /opt/xiaoxia-cmd-agent/server.py
|
|
||||||
echo ""
|
|
||||||
echo "=== Test with X-Token header ==="
|
|
||||||
curl -s -X POST http://127.0.0.1:18888/cmd-agent/exec \
|
|
||||||
-H "X-Token: $(cat /etc/xiaoxia-cmd-agent.token)" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"command":"hostname"}'
|
|
||||||
echo ""
|
|
||||||
echo "=== Test with token in query string ==="
|
|
||||||
curl -s -X POST "http://127.0.0.1:18888/cmd-agent/exec?token=$(cat /etc/xiaoxia-cmd-agent.token)" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"command":"hostname"}'
|
|
||||||
echo ""
|
|
||||||
echo "=== Check if path is /exec not /cmd-agent/exec ==="
|
|
||||||
curl -s -X POST http://127.0.0.1:18888/exec \
|
|
||||||
-H "Authorization: Bearer $(cat /etc/xiaoxia-cmd-agent.token)" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"command":"hostname"}'
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
name: Read CMD Agent Source
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'debug/cmd-agent'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
read:
|
|
||||||
name: Read CMD Agent server.py
|
|
||||||
runs-on: host
|
|
||||||
timeout-minutes: 3
|
|
||||||
steps:
|
|
||||||
- name: Read source
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "=== CMD Agent server.py (first 80 lines) ==="
|
|
||||||
head -80 /opt/xiaoxia-cmd-agent/server.py
|
|
||||||
echo ""
|
|
||||||
echo "=== Token-related lines ==="
|
|
||||||
grep -n -i "token\|auth\|secret\|key" /opt/xiaoxia-cmd-agent/server.py
|
|
||||||
echo ""
|
|
||||||
echo "=== Systemd service config ==="
|
|
||||||
cat /etc/systemd/system/xiaoxia-cmd-agent.service 2>/dev/null || echo "no systemd service"
|
|
||||||
echo ""
|
|
||||||
echo "=== Environment variables from process ==="
|
|
||||||
cat /proc/1034/environ 2>/dev/null | tr '\0' '\n' | grep -i "token\|auth\|secret\|key" || echo "no env vars found"
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
name: Read CMD Agent Token
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'debug/cmd-agent'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
read:
|
|
||||||
name: Read Real Token
|
|
||||||
runs-on: host
|
|
||||||
timeout-minutes: 3
|
|
||||||
steps:
|
|
||||||
- name: Read
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "=== Real CMD Agent Token ==="
|
|
||||||
cat /etc/xiaoxia-cmd-agent.token
|
|
||||||
echo ""
|
|
||||||
echo "=== Test with real token ==="
|
|
||||||
curl -s -X POST http://127.0.0.1:18888/cmd-agent/exec \
|
|
||||||
-H "Authorization: Bearer $(cat /etc/xiaoxia-cmd-agent.token)" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"command":"hostname && whoami"}'
|
|
||||||
echo ""
|
|
||||||
echo "=== Nginx config for cmd-agent (full) ==="
|
|
||||||
sed -n '/cmd-agent/,/}/p' /etc/nginx/sites-enabled/00-xiaoxia-saas | head -20
|
|
||||||
echo ""
|
|
||||||
echo "=== All listening ports ==="
|
|
||||||
ss -tlnp | head -20
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""add error_info and retry fields to generation_tasks
|
||||||
|
|
||||||
|
Revision ID: 038_error_retry
|
||||||
|
Revises: 037_generation_logs
|
||||||
|
Create Date: 2026-07-13 22:15:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.mysql import JSON as MySQLJSON
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "038_error_retry"
|
||||||
|
down_revision = "037_generation_logs"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# error_info: 结构化错误信息(error_type, message, stack_trace, failed_at, stage等)
|
||||||
|
op.add_column(
|
||||||
|
"generation_tasks",
|
||||||
|
sa.Column("error_info", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
# retry_count: 重试次数
|
||||||
|
op.add_column(
|
||||||
|
"generation_tasks",
|
||||||
|
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
# auto_retry_enabled: 是否开启自动重试
|
||||||
|
op.add_column(
|
||||||
|
"generation_tasks",
|
||||||
|
sa.Column("auto_retry_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
)
|
||||||
|
# auto_retry_max: 最大自动重试次数
|
||||||
|
op.add_column(
|
||||||
|
"generation_tasks",
|
||||||
|
sa.Column("auto_retry_max", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("generation_tasks", "auto_retry_max")
|
||||||
|
op.drop_column("generation_tasks", "auto_retry_enabled")
|
||||||
|
op.drop_column("generation_tasks", "retry_count")
|
||||||
|
op.drop_column("generation_tasks", "error_info")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""add transition_duration to edit_plan_clips
|
||||||
|
|
||||||
|
Revision ID: 039_transition_duration
|
||||||
|
Revises: 038_error_retry
|
||||||
|
Create Date: 2026-07-14 09:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "039_transition_duration"
|
||||||
|
down_revision = "038_error_retry"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"edit_plan_clips",
|
||||||
|
sa.Column(
|
||||||
|
"transition_duration",
|
||||||
|
sa.Float(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="0.0",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("edit_plan_clips", "transition_duration")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add playback_speed to edit_plan_clips
|
||||||
|
|
||||||
|
Revision ID: 040_playback_speed
|
||||||
|
Revises: 039_transition_duration
|
||||||
|
Create Date: 2026-07-14 10:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "040_playback_speed"
|
||||||
|
down_revision = "039_transition_duration"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"edit_plan_clips",
|
||||||
|
sa.Column("playback_speed", sa.Float(), nullable=False, server_default="1.0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("edit_plan_clips", "playback_speed")
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""API application package."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""API package."""
|
|
||||||
+15
-29
@@ -4,17 +4,14 @@ from app.api.routes.assets import router as assets_router
|
|||||||
from app.api.routes.auth import router as auth_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.chunked_upload import router as chunked_upload_router
|
||||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||||
from app.api.routes.dashboard import router as dashboard_router
|
|
||||||
from app.api.routes.duplication import router as duplication_router
|
from app.api.routes.duplication import router as duplication_router
|
||||||
from app.api.routes.edit_plans import router as edit_plans_router
|
from app.api.routes.edit_plans import router as edit_plans_router
|
||||||
from app.api.routes.edit_templates import router as edit_templates_router
|
from app.api.routes.feature_flags import router as feature_flags_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.generation_tasks import router as generation_tasks_router
|
||||||
from app.api.routes.health import router as health_check_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.ingest_jobs import router as ingest_jobs_router
|
||||||
from app.api.routes.jobs import router as jobs_router
|
from app.api.routes.internal_render import router as internal_render_router
|
||||||
from app.api.routes.projects import router as projects_router
|
from app.api.routes.projects import router as projects_router
|
||||||
from app.api.routes.recipes import router as recipes_router
|
|
||||||
from app.api.routes.subscription import router as subscription_router
|
from app.api.routes.subscription import router as subscription_router
|
||||||
from app.api.routes.tags import router as tags_router
|
from app.api.routes.tags import router as tags_router
|
||||||
from app.api.routes.task_center import router as task_center_router
|
from app.api.routes.task_center import router as task_center_router
|
||||||
@@ -22,6 +19,7 @@ from app.api.routes.templates import router as templates_router
|
|||||||
from app.api.routes.titles import router as titles_router
|
from app.api.routes.titles import router as titles_router
|
||||||
from app.api.routes.tts import router as tts_router
|
from app.api.routes.tts import router as tts_router
|
||||||
from app.api.routes.upload import router as upload_router
|
from app.api.routes.upload import router as upload_router
|
||||||
|
from app.api.routes.videos import router as videos_router
|
||||||
from app.api.routes.voice_clones import router as voice_clones_router
|
from app.api.routes.voice_clones import router as voice_clones_router
|
||||||
from app.api.routes.voices import router as voices_router
|
from app.api.routes.voices import router as voices_router
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
@@ -87,15 +85,6 @@ api_router.include_router(
|
|||||||
prefix="/generation",
|
prefix="/generation",
|
||||||
tags=["Generation"],
|
tags=["Generation"],
|
||||||
)
|
)
|
||||||
api_router.include_router(
|
|
||||||
jobs_router,
|
|
||||||
tags=["Job"],
|
|
||||||
)
|
|
||||||
api_router.include_router(
|
|
||||||
generated_videos_router,
|
|
||||||
prefix="/generated-videos",
|
|
||||||
tags=["GeneratedVideo"],
|
|
||||||
)
|
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
titles_router,
|
titles_router,
|
||||||
prefix="/titles",
|
prefix="/titles",
|
||||||
@@ -111,6 +100,10 @@ api_router.include_router(
|
|||||||
prefix="/voice-clones",
|
prefix="/voice-clones",
|
||||||
tags=["VoiceClone"],
|
tags=["VoiceClone"],
|
||||||
)
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
videos_router,
|
||||||
|
tags=["VideoCenter"],
|
||||||
|
)
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
duplication_router,
|
duplication_router,
|
||||||
prefix="/duplication",
|
prefix="/duplication",
|
||||||
@@ -121,26 +114,11 @@ api_router.include_router(
|
|||||||
prefix="/subscription",
|
prefix="/subscription",
|
||||||
tags=["Subscription"],
|
tags=["Subscription"],
|
||||||
)
|
)
|
||||||
api_router.include_router(
|
|
||||||
recipes_router,
|
|
||||||
prefix="/recipes",
|
|
||||||
tags=["Recipe"],
|
|
||||||
)
|
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
templates_router,
|
templates_router,
|
||||||
prefix="/templates",
|
prefix="/templates",
|
||||||
tags=["Template"],
|
tags=["Template"],
|
||||||
)
|
)
|
||||||
api_router.include_router(
|
|
||||||
dashboard_router,
|
|
||||||
prefix="/dashboard",
|
|
||||||
tags=["Dashboard"],
|
|
||||||
)
|
|
||||||
api_router.include_router(
|
|
||||||
edit_templates_router,
|
|
||||||
prefix="/edit-templates",
|
|
||||||
tags=["EditTemplate"],
|
|
||||||
)
|
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
edit_plans_router,
|
edit_plans_router,
|
||||||
prefix="/edit-plans",
|
prefix="/edit-plans",
|
||||||
@@ -151,3 +129,11 @@ api_router.include_router(
|
|||||||
prefix="/tts",
|
prefix="/tts",
|
||||||
tags=["TTS"],
|
tags=["TTS"],
|
||||||
)
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
feature_flags_router,
|
||||||
|
tags=["Internal"],
|
||||||
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
internal_render_router,
|
||||||
|
tags=["Internal"],
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""路由层共享辅助函数 — 消除跨文件重复定义。"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from packages.application import GetProjectUseCase
|
||||||
|
from packages.ports.user_repository import UserRepository
|
||||||
|
|
||||||
|
|
||||||
|
def check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
||||||
|
"""检查用户是否有项目访问权限。
|
||||||
|
|
||||||
|
合并自 asset_libraries.py / edit_plans.py 的同名函数。
|
||||||
|
- 空 project_id 直接放行(兼容 edit_plans 中 project_id 可选的场景)
|
||||||
|
- 错误信息使用中文,与项目其他路由保持一致
|
||||||
|
"""
|
||||||
|
if not project_id or not project_id.strip():
|
||||||
|
return
|
||||||
|
project = project_repository.find_by_id(project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(status_code=404, detail="项目不存在")
|
||||||
|
if not project.can_access(user_id):
|
||||||
|
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_plan(user_id: str, user_repository: UserRepository) -> str:
|
||||||
|
"""获取用户的订阅计划名称。"""
|
||||||
|
user = user_repository.find_by_id(user_id)
|
||||||
|
if user is None:
|
||||||
|
return "free"
|
||||||
|
return getattr(user, "subscription_plan", "free") or "free"
|
||||||
|
|
||||||
|
|
||||||
|
def require_project_and_library(
|
||||||
|
project_id: str,
|
||||||
|
library_id: str,
|
||||||
|
project_repository: Any,
|
||||||
|
asset_library_repository: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Verify project and asset library exist."""
|
||||||
|
project = GetProjectUseCase(project_repository).execute(project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||||
|
|
||||||
|
libraries = asset_library_repository.find_by_project(project_id)
|
||||||
|
if not any(item.id == library_id for item in libraries):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
|
||||||
Regular → Executable
+8
-16
@@ -12,7 +12,7 @@ from app.schemas.asset_library import (
|
|||||||
EnsureDefaultLibraryRequest,
|
EnsureDefaultLibraryRequest,
|
||||||
ListAssetLibrariesResponse,
|
ListAssetLibrariesResponse,
|
||||||
)
|
)
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
|
|
||||||
from packages.application import (
|
from packages.application import (
|
||||||
CreateAssetLibraryCommand,
|
CreateAssetLibraryCommand,
|
||||||
@@ -22,18 +22,11 @@ from packages.application import (
|
|||||||
)
|
)
|
||||||
from packages.domain import AssetLibrary, AssetLibraryKind
|
from packages.domain import AssetLibrary, AssetLibraryKind
|
||||||
|
|
||||||
|
from ._helpers import check_project_access
|
||||||
|
|
||||||
router = APIRouter()
|
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:
|
def _to_asset_library_response(item) -> AssetLibraryResponse:
|
||||||
return AssetLibraryResponse(
|
return AssetLibraryResponse(
|
||||||
id=item.id,
|
id=item.id,
|
||||||
@@ -153,7 +146,7 @@ def ensure_default_library(
|
|||||||
return _to_asset_library_response(created)
|
return _to_asset_library_response(created)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||||
def delete_asset_library(
|
def delete_asset_library(
|
||||||
library_id: str,
|
library_id: str,
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
@@ -168,13 +161,12 @@ def delete_asset_library(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材库不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材库不存在")
|
||||||
|
|
||||||
# 权限校验:检查用户是否有项目访问权限
|
# 权限校验:检查用户是否有项目访问权限
|
||||||
_check_project_access(library.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(library.project_id, authenticated_user.user.id, project_repository)
|
||||||
|
|
||||||
# 删除库内所有素材(无 FK 级联,需手动清理)
|
# 删除库内所有素材(硬删除,素材库已删除,无需保留软删除状态)
|
||||||
assets_in_library = asset_repository.find_by_library(library_id)
|
assets_in_library = asset_repository.find_by_library(library_id)
|
||||||
if assets_in_library:
|
for asset in assets_in_library:
|
||||||
asset_ids_to_delete = [a.id for a in assets_in_library]
|
asset_repository.delete(asset.id)
|
||||||
asset_repository.batch_delete(asset_ids_to_delete)
|
|
||||||
|
|
||||||
# 删除素材库本身
|
# 删除素材库本身
|
||||||
asset_library_repository.delete(library_id)
|
asset_library_repository.delete(library_id)
|
||||||
|
|||||||
Regular → Executable
+196
-39
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from app.api.routes._helpers import check_project_access
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.core.storage import get_storage_service
|
from app.core.storage import get_storage_service
|
||||||
from app.dependencies import (
|
from app.dependencies import (
|
||||||
@@ -11,15 +12,18 @@ from app.dependencies import (
|
|||||||
)
|
)
|
||||||
from app.schemas.asset import (
|
from app.schemas.asset import (
|
||||||
AssetResponse,
|
AssetResponse,
|
||||||
|
BatchClassifyRequest,
|
||||||
BatchDeleteRequest,
|
BatchDeleteRequest,
|
||||||
BatchDeleteResponse,
|
BatchMarkRequest,
|
||||||
|
BatchOperationResponse,
|
||||||
|
BatchTagRequest,
|
||||||
CreateAssetRequest,
|
CreateAssetRequest,
|
||||||
ListAssetsResponse,
|
ListAssetsResponse,
|
||||||
UpdateAssetRequest,
|
UpdateAssetRequest,
|
||||||
UpdateAssetReviewRequest,
|
UpdateAssetReviewRequest,
|
||||||
)
|
)
|
||||||
from app.schemas.tag import TagAssetsRequest
|
from app.schemas.tag import TagAssetsRequest
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
|
|
||||||
from packages.application import (
|
from packages.application import (
|
||||||
CreateAssetCommand,
|
CreateAssetCommand,
|
||||||
@@ -72,15 +76,6 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
|
||||||
"""检查用户是否有项目访问权限"""
|
|
||||||
project = project_repository.find_by_id(project_id)
|
|
||||||
if project is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
|
||||||
if not project.can_access(user_id):
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ListAssetsResponse)
|
@router.get("", response_model=ListAssetsResponse)
|
||||||
def list_assets(
|
def list_assets(
|
||||||
library_id: Optional[str] = Query(None),
|
library_id: Optional[str] = Query(None),
|
||||||
@@ -90,6 +85,15 @@ def list_assets(
|
|||||||
gender: Optional[str] = Query(None, description="按 metadata.gender 筛选"),
|
gender: Optional[str] = Query(None, description="按 metadata.gender 筛选"),
|
||||||
style: Optional[str] = Query(None, description="按 metadata.style 筛选"),
|
style: Optional[str] = Query(None, description="按 metadata.style 筛选"),
|
||||||
tag_ids: Optional[str] = Query(None, description="按标签 ID 筛选(逗号分隔,取交集)"),
|
tag_ids: Optional[str] = Query(None, description="按标签 ID 筛选(逗号分隔,取交集)"),
|
||||||
|
smart_view: Optional[str] = Query(
|
||||||
|
None,
|
||||||
|
description="智能视图筛选:recommended=推荐(质量分≥80)、cautious=慎用(60-79)、risky=高风险(<60或已驳回)、unused=未使用、used=已使用、pending_review=待复核",
|
||||||
|
pattern="^(recommended|cautious|risky|unused|used|pending_review)$",
|
||||||
|
),
|
||||||
|
classification: Optional[str] = Query(
|
||||||
|
None,
|
||||||
|
description="按内容分类筛选:scenic=风景、product=产品、person=人物、animal=动物、food=美食、tech=科技、sport=运动、music=音乐、other=其他",
|
||||||
|
),
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(100, ge=1, le=500),
|
limit: int = Query(100, ge=1, le=500),
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
@@ -109,11 +113,11 @@ def list_assets(
|
|||||||
if not filter_tag_ids:
|
if not filter_tag_ids:
|
||||||
filter_tag_ids = None
|
filter_tag_ids = None
|
||||||
|
|
||||||
# 需要内存过滤的标志(keyword/gender/style/tag_ids 无法在 DB 层过滤)
|
# 需要内存过滤的标志(keyword/gender/style/tag_ids/smart_view/classification 无法在 DB 层过滤)
|
||||||
needs_memory_filter = bool(keyword or gender or style or filter_tag_ids)
|
needs_memory_filter = bool(keyword or gender or style or filter_tag_ids or smart_view or classification)
|
||||||
|
|
||||||
def _apply_memory_filters(items):
|
def _apply_memory_filters(items):
|
||||||
"""应用 keyword / gender / style / tag_ids 内存过滤。"""
|
"""应用 keyword / gender / style / tag_ids / smart_view / classification 内存过滤。"""
|
||||||
result = items
|
result = items
|
||||||
if keyword:
|
if keyword:
|
||||||
kw = keyword.lower()
|
kw = keyword.lower()
|
||||||
@@ -122,9 +126,38 @@ def list_assets(
|
|||||||
result = [i for i in result if (i.metadata or {}).get("gender") == gender]
|
result = [i for i in result if (i.metadata or {}).get("gender") == gender]
|
||||||
if style:
|
if style:
|
||||||
result = [i for i in result if (i.metadata or {}).get("style") == style]
|
result = [i for i in result if (i.metadata or {}).get("style") == style]
|
||||||
|
if classification:
|
||||||
|
result = [i for i in result if (i.metadata or {}).get("classification") == classification]
|
||||||
if filter_tag_ids:
|
if filter_tag_ids:
|
||||||
tag_set = set(filter_tag_ids)
|
tag_set = set(filter_tag_ids)
|
||||||
result = [i for i in result if tag_set.issubset(set(getattr(i, "tag_ids", [])))]
|
result = [i for i in result if tag_set.issubset(set(getattr(i, "tag_ids", [])))]
|
||||||
|
if smart_view:
|
||||||
|
|
||||||
|
def __meta(a):
|
||||||
|
return a.metadata or {}
|
||||||
|
|
||||||
|
def __use_count(a):
|
||||||
|
return int(__meta(a).get("generation_use_count") or 0)
|
||||||
|
|
||||||
|
def __review_status(a):
|
||||||
|
return __meta(a).get("review_status", "")
|
||||||
|
|
||||||
|
if smart_view == "recommended":
|
||||||
|
result = [i for i in result if i.quality_score is not None and i.quality_score >= 80]
|
||||||
|
elif smart_view == "cautious":
|
||||||
|
result = [i for i in result if i.quality_score is not None and 60 <= i.quality_score < 80]
|
||||||
|
elif smart_view == "risky":
|
||||||
|
result = [
|
||||||
|
i
|
||||||
|
for i in result
|
||||||
|
if (i.quality_score is not None and i.quality_score < 60) or __review_status(i) == "rejected"
|
||||||
|
]
|
||||||
|
elif smart_view == "unused":
|
||||||
|
result = [i for i in result if __use_count(i) == 0]
|
||||||
|
elif smart_view == "used":
|
||||||
|
result = [i for i in result if __use_count(i) > 0]
|
||||||
|
elif smart_view == "pending_review":
|
||||||
|
result = [i for i in result if __review_status(i) == "pending_review"]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── 优化路径:无内存过滤时,使用 DB 级分页 ──
|
# ── 优化路径:无内存过滤时,使用 DB 级分页 ──
|
||||||
@@ -136,7 +169,7 @@ def list_assets(
|
|||||||
library = asset_library_repository.get(library_id)
|
library = asset_library_repository.get(library_id)
|
||||||
if library is None:
|
if library is None:
|
||||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
||||||
_check_project_access(library.project_id, user_id, project_repository)
|
check_project_access(library.project_id, user_id, project_repository)
|
||||||
if ft:
|
if ft:
|
||||||
items = asset_repository.find_by_library_and_file_type(library_id, ft, skip=skip, limit=limit)
|
items = asset_repository.find_by_library_and_file_type(library_id, ft, skip=skip, limit=limit)
|
||||||
total = asset_repository.count_by_project(library.project_id) if not kind else len(items)
|
total = asset_repository.count_by_project(library.project_id) if not kind else len(items)
|
||||||
@@ -152,7 +185,7 @@ def list_assets(
|
|||||||
|
|
||||||
# 模式2:指定 project_id
|
# 模式2:指定 project_id
|
||||||
if project_id:
|
if project_id:
|
||||||
_check_project_access(project_id, user_id, project_repository)
|
check_project_access(project_id, user_id, project_repository)
|
||||||
if ft:
|
if ft:
|
||||||
# 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
|
# 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
|
||||||
all_items = asset_repository.find_by_project(project_id)
|
all_items = asset_repository.find_by_project(project_id)
|
||||||
@@ -210,13 +243,13 @@ def list_assets(
|
|||||||
library = asset_library_repository.get(library_id)
|
library = asset_library_repository.get(library_id)
|
||||||
if library is None:
|
if library is None:
|
||||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
||||||
_check_project_access(library.project_id, user_id, project_repository)
|
check_project_access(library.project_id, user_id, project_repository)
|
||||||
if kind:
|
if kind:
|
||||||
all_items = asset_repository.find_by_library_and_file_type(library_id, kind_to_file_type[kind])
|
all_items = asset_repository.find_by_library_and_file_type(library_id, kind_to_file_type[kind])
|
||||||
else:
|
else:
|
||||||
all_items = asset_repository.find_by_library(library_id)
|
all_items = asset_repository.find_by_library(library_id)
|
||||||
elif project_id:
|
elif project_id:
|
||||||
_check_project_access(project_id, user_id, project_repository)
|
check_project_access(project_id, user_id, project_repository)
|
||||||
all_items = asset_repository.find_by_project(project_id)
|
all_items = asset_repository.find_by_project(project_id)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
@@ -262,39 +295,163 @@ def update_asset_review_status(
|
|||||||
item = asset_repository.get(asset_id)
|
item = asset_repository.get(asset_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||||
_apply_asset_review_status(item, request.review_status)
|
_apply_asset_review_status(item, request.review_status)
|
||||||
updated = asset_repository.update(item)
|
updated = asset_repository.update(item)
|
||||||
return _to_asset_response(updated)
|
return _to_asset_response(updated)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch-delete", response_model=BatchDeleteResponse)
|
@router.post("/batch-delete", response_model=BatchOperationResponse)
|
||||||
def batch_delete_assets(
|
def batch_delete_assets(
|
||||||
request: BatchDeleteRequest,
|
request: BatchDeleteRequest,
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
asset_repository: Any = Depends(get_asset_repository),
|
asset_repository: Any = Depends(get_asset_repository),
|
||||||
project_repository: Any = Depends(get_project_repository),
|
project_repository: Any = Depends(get_project_repository),
|
||||||
) -> BatchDeleteResponse:
|
) -> BatchOperationResponse:
|
||||||
"""批量删除素材(配音素材等),需逐项校验项目权限。"""
|
"""批量删除素材(软删除,标记 status=deleted),需逐项校验项目权限。"""
|
||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
deleted_ids: list[str] = []
|
success_ids: list[str] = []
|
||||||
failed_ids: list[str] = []
|
failed_details: dict[str, str] = {}
|
||||||
|
|
||||||
for asset_id in request.ids:
|
for asset_id in request.asset_ids:
|
||||||
item = asset_repository.find_by_id(asset_id)
|
item = asset_repository.find_by_id(asset_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
failed_ids.append(asset_id)
|
failed_details[asset_id] = "not_found"
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
_check_project_access(item.project_id, user_id, project_repository)
|
check_project_access(item.project_id, user_id, project_repository)
|
||||||
deleted_ids.append(asset_id)
|
success_ids.append(asset_id)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
failed_ids.append(asset_id)
|
failed_details[asset_id] = "access_denied"
|
||||||
|
|
||||||
if deleted_ids:
|
if success_ids:
|
||||||
asset_repository.batch_delete(deleted_ids)
|
asset_repository.batch_delete(success_ids)
|
||||||
|
|
||||||
return BatchDeleteResponse(deleted_count=len(deleted_ids), failed_ids=failed_ids)
|
return BatchOperationResponse(
|
||||||
|
success_count=len(success_ids),
|
||||||
|
failed_ids=list(failed_details.keys()),
|
||||||
|
failed_details=failed_details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch-tag", response_model=BatchOperationResponse)
|
||||||
|
def batch_tag_assets(
|
||||||
|
request: BatchTagRequest,
|
||||||
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
asset_repository: Any = Depends(get_asset_repository),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
tag_repository: Any = Depends(get_tag_repository),
|
||||||
|
) -> BatchOperationResponse:
|
||||||
|
"""批量打标签(添加或替换模式),需逐项校验项目权限和标签权限。"""
|
||||||
|
user_id = authenticated_user.user.id
|
||||||
|
success_ids: list[str] = []
|
||||||
|
failed_details: dict[str, str] = {}
|
||||||
|
|
||||||
|
# 校验标签存在且属于当前用户
|
||||||
|
for tag_id in request.tag_ids:
|
||||||
|
tag = tag_repository.get(tag_id)
|
||||||
|
if tag is None:
|
||||||
|
return BatchOperationResponse(
|
||||||
|
success_count=0,
|
||||||
|
failed_ids=list(request.asset_ids),
|
||||||
|
failed_details={aid: f"tag_not_found:{tag_id}" for aid in request.asset_ids},
|
||||||
|
)
|
||||||
|
if tag.user_id != user_id:
|
||||||
|
return BatchOperationResponse(
|
||||||
|
success_count=0,
|
||||||
|
failed_ids=list(request.asset_ids),
|
||||||
|
failed_details={aid: f"tag_access_denied:{tag_id}" for aid in request.asset_ids},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 校验素材权限
|
||||||
|
for asset_id in request.asset_ids:
|
||||||
|
item = asset_repository.find_by_id(asset_id)
|
||||||
|
if item is None:
|
||||||
|
failed_details[asset_id] = "not_found"
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
check_project_access(item.project_id, user_id, project_repository)
|
||||||
|
success_ids.append(asset_id)
|
||||||
|
except HTTPException:
|
||||||
|
failed_details[asset_id] = "access_denied"
|
||||||
|
|
||||||
|
if success_ids:
|
||||||
|
if request.mode == "replace":
|
||||||
|
asset_repository.batch_replace_tags(success_ids, request.tag_ids)
|
||||||
|
else:
|
||||||
|
asset_repository.batch_add_tags(success_ids, request.tag_ids)
|
||||||
|
|
||||||
|
return BatchOperationResponse(
|
||||||
|
success_count=len(success_ids),
|
||||||
|
failed_ids=list(failed_details.keys()),
|
||||||
|
failed_details=failed_details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch-classify", response_model=BatchOperationResponse)
|
||||||
|
def batch_classify_assets(
|
||||||
|
request: BatchClassifyRequest,
|
||||||
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
asset_repository: Any = Depends(get_asset_repository),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> BatchOperationResponse:
|
||||||
|
"""批量修改素材内容分类(person/scenic/product等),存在metadata.category中。"""
|
||||||
|
user_id = authenticated_user.user.id
|
||||||
|
success_ids: list[str] = []
|
||||||
|
failed_details: dict[str, str] = {}
|
||||||
|
|
||||||
|
for asset_id in request.asset_ids:
|
||||||
|
item = asset_repository.find_by_id(asset_id)
|
||||||
|
if item is None:
|
||||||
|
failed_details[asset_id] = "not_found"
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
check_project_access(item.project_id, user_id, project_repository)
|
||||||
|
success_ids.append(asset_id)
|
||||||
|
except HTTPException:
|
||||||
|
failed_details[asset_id] = "access_denied"
|
||||||
|
|
||||||
|
if success_ids:
|
||||||
|
asset_repository.batch_update_metadata(success_ids, {"category": request.category})
|
||||||
|
|
||||||
|
return BatchOperationResponse(
|
||||||
|
success_count=len(success_ids),
|
||||||
|
failed_ids=list(failed_details.keys()),
|
||||||
|
failed_details=failed_details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch-mark", response_model=BatchOperationResponse)
|
||||||
|
def batch_mark_assets(
|
||||||
|
request: BatchMarkRequest,
|
||||||
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
asset_repository: Any = Depends(get_asset_repository),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> BatchOperationResponse:
|
||||||
|
"""批量设置智能视图标记(recommended/caution/high_risk),存在metadata.smart_view中。"""
|
||||||
|
user_id = authenticated_user.user.id
|
||||||
|
success_ids: list[str] = []
|
||||||
|
failed_details: dict[str, str] = {}
|
||||||
|
|
||||||
|
for asset_id in request.asset_ids:
|
||||||
|
item = asset_repository.find_by_id(asset_id)
|
||||||
|
if item is None:
|
||||||
|
failed_details[asset_id] = "not_found"
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
check_project_access(item.project_id, user_id, project_repository)
|
||||||
|
success_ids.append(asset_id)
|
||||||
|
except HTTPException:
|
||||||
|
failed_details[asset_id] = "access_denied"
|
||||||
|
|
||||||
|
if success_ids:
|
||||||
|
asset_repository.batch_update_metadata(success_ids, {"smart_view": request.smart_view})
|
||||||
|
|
||||||
|
return BatchOperationResponse(
|
||||||
|
success_count=len(success_ids),
|
||||||
|
failed_ids=list(failed_details.keys()),
|
||||||
|
failed_details=failed_details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{asset_id}", response_model=AssetResponse)
|
@router.get("/{asset_id}", response_model=AssetResponse)
|
||||||
@@ -307,7 +464,7 @@ def get_asset(
|
|||||||
item = asset_repository.find_by_id(asset_id)
|
item = asset_repository.find_by_id(asset_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||||
return _to_asset_response(item)
|
return _to_asset_response(item)
|
||||||
|
|
||||||
|
|
||||||
@@ -322,7 +479,7 @@ def update_asset(
|
|||||||
item = asset_repository.find_by_id(asset_id)
|
item = asset_repository.find_by_id(asset_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||||
|
|
||||||
# 合并可修改字段
|
# 合并可修改字段
|
||||||
if request.name is not None:
|
if request.name is not None:
|
||||||
@@ -336,7 +493,7 @@ def update_asset(
|
|||||||
return _to_asset_response(updated)
|
return _to_asset_response(updated)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{asset_id}", status_code=204)
|
@router.delete("/{asset_id}", status_code=204, response_class=Response)
|
||||||
def delete_asset(
|
def delete_asset(
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
@@ -346,7 +503,7 @@ def delete_asset(
|
|||||||
item = asset_repository.find_by_id(asset_id)
|
item = asset_repository.find_by_id(asset_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||||
asset_repository.delete(asset_id)
|
asset_repository.delete(asset_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -363,7 +520,7 @@ def tag_asset(
|
|||||||
item = asset_repository.find_by_id(asset_id)
|
item = asset_repository.find_by_id(asset_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||||
for tag_id in request.tag_ids:
|
for tag_id in request.tag_ids:
|
||||||
tag = tag_repository.get(tag_id)
|
tag = tag_repository.get(tag_id)
|
||||||
if tag is None:
|
if tag is None:
|
||||||
@@ -375,7 +532,7 @@ def tag_asset(
|
|||||||
return _to_asset_response(updated)
|
return _to_asset_response(updated)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{asset_id}/tags/{tag_id}", status_code=204)
|
@router.delete("/{asset_id}/tags/{tag_id}", status_code=204, response_class=Response)
|
||||||
def untag_asset(
|
def untag_asset(
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
tag_id: str,
|
tag_id: str,
|
||||||
@@ -387,7 +544,7 @@ def untag_asset(
|
|||||||
item = asset_repository.find_by_id(asset_id)
|
item = asset_repository.find_by_id(asset_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||||
item.remove_tag(tag_id)
|
item.remove_tag(tag_id)
|
||||||
asset_repository.update(item)
|
asset_repository.update(item)
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ async def register(
|
|||||||
request: RegisterRequest,
|
request: RegisterRequest,
|
||||||
user_repository: UserRepository = Depends(get_user_repository),
|
user_repository: UserRepository = Depends(get_user_repository),
|
||||||
email_service=Depends(get_auth_email_service),
|
email_service=Depends(get_auth_email_service),
|
||||||
):
|
) -> RegisterResponse:
|
||||||
use_case = RegisterUserUseCase(
|
use_case = RegisterUserUseCase(
|
||||||
user_repository=user_repository,
|
user_repository=user_repository,
|
||||||
base_url=settings.APP_BASE_URL,
|
base_url=settings.APP_BASE_URL,
|
||||||
@@ -136,7 +136,7 @@ async def login(
|
|||||||
request: LoginRequest,
|
request: LoginRequest,
|
||||||
user_repository: UserRepository = Depends(get_user_repository),
|
user_repository: UserRepository = Depends(get_user_repository),
|
||||||
session_store=Depends(get_auth_session_store),
|
session_store=Depends(get_auth_session_store),
|
||||||
):
|
) -> LoginResponse:
|
||||||
use_case = LoginUseCase(
|
use_case = LoginUseCase(
|
||||||
user_repository=user_repository,
|
user_repository=user_repository,
|
||||||
session_store=session_store,
|
session_store=session_store,
|
||||||
@@ -162,7 +162,7 @@ async def refresh(
|
|||||||
request: RefreshRequest,
|
request: RefreshRequest,
|
||||||
user_repository: UserRepository = Depends(get_user_repository),
|
user_repository: UserRepository = Depends(get_user_repository),
|
||||||
session_store=Depends(get_auth_session_store),
|
session_store=Depends(get_auth_session_store),
|
||||||
):
|
) -> LoginResponse:
|
||||||
use_case = RefreshTokenUseCase(
|
use_case = RefreshTokenUseCase(
|
||||||
user_repository=user_repository,
|
user_repository=user_repository,
|
||||||
session_store=session_store,
|
session_store=session_store,
|
||||||
@@ -194,7 +194,7 @@ def _verify_email_token(token: str, user_repository: UserRepository) -> MessageR
|
|||||||
async def verify_email(
|
async def verify_email(
|
||||||
token: str,
|
token: str,
|
||||||
user_repository: UserRepository = Depends(get_user_repository),
|
user_repository: UserRepository = Depends(get_user_repository),
|
||||||
):
|
) -> MessageResponse:
|
||||||
return _verify_email_token(token, user_repository)
|
return _verify_email_token(token, user_repository)
|
||||||
|
|
||||||
|
|
||||||
@@ -202,16 +202,16 @@ async def verify_email(
|
|||||||
async def verify_email_post(
|
async def verify_email_post(
|
||||||
request: VerifyEmailRequestModel,
|
request: VerifyEmailRequestModel,
|
||||||
user_repository: UserRepository = Depends(get_user_repository),
|
user_repository: UserRepository = Depends(get_user_repository),
|
||||||
):
|
) -> MessageResponse:
|
||||||
return _verify_email_token(request.token, 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(
|
async def forgot_password(
|
||||||
request: PasswordResetRequestModel,
|
request: PasswordResetRequestModel,
|
||||||
user_repository: UserRepository = Depends(get_user_repository),
|
user_repository: UserRepository = Depends(get_user_repository),
|
||||||
email_service=Depends(get_auth_email_service),
|
email_service=Depends(get_auth_email_service),
|
||||||
):
|
) -> MessageResponse:
|
||||||
success, error = RequestPasswordResetUseCase(
|
success, error = RequestPasswordResetUseCase(
|
||||||
user_repository=user_repository,
|
user_repository=user_repository,
|
||||||
base_url=settings.APP_BASE_URL,
|
base_url=settings.APP_BASE_URL,
|
||||||
@@ -223,11 +223,11 @@ async def forgot_password(
|
|||||||
return MessageResponse(message="如果账户存在,密码重置邮件已发送")
|
return MessageResponse(message="如果账户存在,密码重置邮件已发送")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/password/reset", response_model=MessageResponse)
|
@router.post("/reset-password", response_model=MessageResponse)
|
||||||
async def reset_password(
|
async def reset_password(
|
||||||
request: ResetPasswordModel,
|
request: ResetPasswordModel,
|
||||||
user_repository: UserRepository = Depends(get_user_repository),
|
user_repository: UserRepository = Depends(get_user_repository),
|
||||||
):
|
) -> MessageResponse:
|
||||||
success, error = ResetPasswordUseCase(user_repository=user_repository).execute(
|
success, error = ResetPasswordUseCase(user_repository=user_repository).execute(
|
||||||
ResetPasswordRequest(token=request.token, new_password=request.new_password)
|
ResetPasswordRequest(token=request.token, new_password=request.new_password)
|
||||||
)
|
)
|
||||||
@@ -241,9 +241,8 @@ async def reset_password(
|
|||||||
async def logout(
|
async def logout(
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
):
|
) -> MessageResponse:
|
||||||
"""登出 - 将当前 token 加入黑名单"""
|
"""登出 - 将当前 token 加入黑名单"""
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
|
|
||||||
if credentials:
|
if credentials:
|
||||||
try:
|
try:
|
||||||
@@ -258,7 +257,7 @@ async def logout(
|
|||||||
@router.get("/me", response_model=CurrentUserResponse)
|
@router.get("/me", response_model=CurrentUserResponse)
|
||||||
async def get_current_user_info(
|
async def get_current_user_info(
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
):
|
) -> CurrentUserResponse:
|
||||||
user = authenticated_user.user
|
user = authenticated_user.user
|
||||||
return CurrentUserResponse(
|
return CurrentUserResponse(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -324,7 +323,7 @@ def _get_internal_api_keys() -> list[str]:
|
|||||||
if content:
|
if content:
|
||||||
return [k.strip() for k in content.split(",") if k.strip()]
|
return [k.strip() for k in content.split(",") if k.strip()]
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to read internal API keys from file", exc_info=True)
|
logger.warning("无法读取内部 API 密钥文件,仅依赖环境变量配置", exc_info=True)
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -355,7 +354,7 @@ async def wechat_sync(
|
|||||||
request: WechatSyncRequest,
|
request: WechatSyncRequest,
|
||||||
user_repository: UserRepository = Depends(get_user_repository),
|
user_repository: UserRepository = Depends(get_user_repository),
|
||||||
_: bool = Depends(_verify_internal_api_key),
|
_: bool = Depends(_verify_internal_api_key),
|
||||||
):
|
) -> WechatSyncResponse:
|
||||||
"""
|
"""
|
||||||
微信同步登录/注册(系统级内部接口)
|
微信同步登录/注册(系统级内部接口)
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.api.routes._helpers import require_project_and_library
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
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.celery_app import celery_app
|
||||||
from app.core.storage import OSSStorageService, get_storage_service
|
from app.core.storage import OSSStorageService, get_storage_service
|
||||||
from app.dependencies import (
|
from app.dependencies import (
|
||||||
@@ -113,22 +113,6 @@ def _atomic_check_and_record(upload_id: str, chunk_index: int) -> bool:
|
|||||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
def _require_project_and_library(
|
|
||||||
project_id: str,
|
|
||||||
library_id: str,
|
|
||||||
project_repository: Any,
|
|
||||||
asset_library_repository: Any,
|
|
||||||
) -> None:
|
|
||||||
"""Verify project and asset library exist"""
|
|
||||||
project = GetProjectUseCase(project_repository).execute(project_id)
|
|
||||||
if project is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
|
||||||
|
|
||||||
libraries = asset_library_repository.find_by_project(project_id)
|
|
||||||
if not any(item.id == library_id for item in libraries):
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
|
|
||||||
|
|
||||||
|
|
||||||
def _load_upload_meta(upload_id: str) -> dict[str, Any]:
|
def _load_upload_meta(upload_id: str) -> dict[str, Any]:
|
||||||
"""Load upload metadata"""
|
"""Load upload metadata"""
|
||||||
meta_path = _get_upload_meta_path(upload_id)
|
meta_path = _get_upload_meta_path(upload_id)
|
||||||
@@ -206,7 +190,6 @@ async def init_chunked_upload(
|
|||||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||||
) -> ChunkedUploadInitResponse:
|
) -> ChunkedUploadInitResponse:
|
||||||
"""Initialize chunked upload"""
|
"""Initialize chunked upload"""
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
# Validate file size
|
# Validate file size
|
||||||
if request.file_size > MAX_FILE_SIZE:
|
if request.file_size > MAX_FILE_SIZE:
|
||||||
@@ -221,7 +204,7 @@ async def init_chunked_upload(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||||
|
|
||||||
# Verify asset library
|
# Verify asset library
|
||||||
_require_project_and_library(
|
require_project_and_library(
|
||||||
request.project_id,
|
request.project_id,
|
||||||
request.library_id,
|
request.library_id,
|
||||||
project_repository,
|
project_repository,
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
from app.dependencies import (
|
|
||||||
get_asset_repository,
|
|
||||||
get_generation_task_repository,
|
|
||||||
get_project_repository,
|
|
||||||
get_title_library_repository,
|
|
||||||
get_voice_library_repository,
|
|
||||||
)
|
|
||||||
from app.schemas.dashboard import DashboardOverviewResponse, RecentTaskItem, SubscriptionInfo
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _status_value(status) -> str:
|
|
||||||
return status.value if hasattr(status, "value") else str(status)
|
|
||||||
|
|
||||||
|
|
||||||
def _generation_step(status: str) -> str:
|
|
||||||
if status == "pending":
|
|
||||||
return "等待 Worker 执行"
|
|
||||||
if status == "running":
|
|
||||||
return "正在生成成片"
|
|
||||||
if status == "completed":
|
|
||||||
return "生成完成"
|
|
||||||
if status == "failed":
|
|
||||||
return "生成失败"
|
|
||||||
return status
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/overview", response_model=DashboardOverviewResponse)
|
|
||||||
def get_dashboard_overview(
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
project_repository: Any = Depends(get_project_repository),
|
|
||||||
asset_repository: Any = Depends(get_asset_repository),
|
|
||||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
|
||||||
title_library_repository: Any = Depends(get_title_library_repository),
|
|
||||||
voice_library_repository: Any = Depends(get_voice_library_repository),
|
|
||||||
) -> DashboardOverviewResponse:
|
|
||||||
"""Dashboard 概览:用户级汇总数据。"""
|
|
||||||
user_id = authenticated_user.user.id
|
|
||||||
|
|
||||||
# 获取用户可访问的所有 project
|
|
||||||
projects = project_repository.find_accessible_projects(user_id)
|
|
||||||
project_ids = [p.id for p in projects]
|
|
||||||
|
|
||||||
# 素材统计
|
|
||||||
total_assets = asset_repository.count_by_project_ids(project_ids)
|
|
||||||
used_storage_bytes = asset_repository.sum_storage_by_project_ids(project_ids)
|
|
||||||
|
|
||||||
# 标题库 / 配音库统计
|
|
||||||
total_titles = title_library_repository.count_by_user(user_id)
|
|
||||||
total_voices = voice_library_repository.count_by_user(user_id)
|
|
||||||
|
|
||||||
# 生成任务统计
|
|
||||||
total_tasks = generation_task_repository.count_by_user(user_id)
|
|
||||||
|
|
||||||
# 最近任务(SQL 层 LIMIT 5)
|
|
||||||
recent = generation_task_repository.list_recent_by_user(user_id, limit=5)
|
|
||||||
recent_tasks = []
|
|
||||||
for task in recent:
|
|
||||||
s = _status_value(task.status)
|
|
||||||
recent_tasks.append(
|
|
||||||
RecentTaskItem(
|
|
||||||
id=task.id,
|
|
||||||
task_type="generation",
|
|
||||||
status=s,
|
|
||||||
current_step=_generation_step(s),
|
|
||||||
error_message=task.error_message or "",
|
|
||||||
updated_at=task.completed_at or task.started_at or task.created_at,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 订阅信息
|
|
||||||
user = authenticated_user.user
|
|
||||||
subscription = SubscriptionInfo(
|
|
||||||
plan=getattr(user, "subscription_plan", "free") or "free",
|
|
||||||
is_active=getattr(user, "subscription_status", "") == "active",
|
|
||||||
)
|
|
||||||
|
|
||||||
return DashboardOverviewResponse(
|
|
||||||
total_assets=total_assets,
|
|
||||||
used_storage_bytes=used_storage_bytes,
|
|
||||||
total_titles=total_titles,
|
|
||||||
total_voices=total_voices,
|
|
||||||
total_tasks=total_tasks,
|
|
||||||
total_products=len(projects),
|
|
||||||
subscription=subscription,
|
|
||||||
recent_tasks=recent_tasks,
|
|
||||||
)
|
|
||||||
@@ -239,7 +239,7 @@ def get_duplication_detail(
|
|||||||
return _to_detail_response(record)
|
return _to_detail_response(record)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||||
def delete_duplication_record(
|
def delete_duplication_record(
|
||||||
record_id: str,
|
record_id: str,
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
@@ -257,7 +257,7 @@ def delete_duplication_record(
|
|||||||
|
|
||||||
use_case = DeleteDuplicationRecordUseCase(duplication_repository)
|
use_case = DeleteDuplicationRecordUseCase(duplication_repository)
|
||||||
use_case.execute(record_id)
|
use_case.execute(record_id)
|
||||||
return Response(status_code=204)
|
return
|
||||||
|
|
||||||
|
|
||||||
@router.post("/records/{record_id}/retry", response_model=DuplicationUploadResponse)
|
@router.post("/records/{record_id}/retry", response_model=DuplicationUploadResponse)
|
||||||
|
|||||||
Executable → Regular
+27
-796
@@ -6,12 +6,11 @@ RESTful CRUD for EditPlan:
|
|||||||
- POST /api/v1/edit-plans 创建
|
- POST /api/v1/edit-plans 创建
|
||||||
- PUT /api/v1/edit-plans/{id} 更新(含状态机流转)
|
- PUT /api/v1/edit-plans/{id} 更新(含状态机流转)
|
||||||
- DELETE /api/v1/edit-plans/{id} 删除
|
- DELETE /api/v1/edit-plans/{id} 删除
|
||||||
- POST /api/v1/edit-plans/{id}/generate 触发剪辑渲染生成(任务 2.05)
|
|
||||||
- GET /api/v1/edit-plans/{id}/generation-status 查询生成进度(任务 2.05)
|
拆分模块(各自独立 router,由本文件 include_router 聚合):
|
||||||
- POST /api/v1/edit-plans/{id}/ai-recommend AI 推荐片段方案(任务 3.09)
|
- edit_plans_generation.py 生成相关(generate / generation-status / generations)
|
||||||
- POST /api/v1/edit-plans/{id}/generate-cover AI 生成封面(任务 3.09)
|
- edit_plans_ai.py AI 推荐 & 封面(ai-recommend / generate-cover)
|
||||||
- GET /api/v1/edit-plans/{id}/timeline 时间线场景数据
|
- edit_plans_timeline.py 时间线 & 模板生成(timeline / generate-from-template)
|
||||||
- POST /api/v1/edit-plans/generate-from-template 基于模板+素材自动生成剪辑计划
|
|
||||||
|
|
||||||
业务逻辑委托给 EditPlanService 服务层。
|
业务逻辑委托给 EditPlanService 服务层。
|
||||||
"""
|
"""
|
||||||
@@ -23,37 +22,18 @@ from datetime import datetime
|
|||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.core.celery_app import celery_app
|
from app.dependencies import get_db_session, get_project_repository
|
||||||
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
|
|
||||||
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
|
|
||||||
from app.schemas.generation_task import GenerationTaskResponse
|
from app.schemas.generation_task import GenerationTaskResponse
|
||||||
from app.services import EditPlanService, PlanGeneratorService
|
from app.services import EditPlanService
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from packages.adapters.sqlalchemy_impl.asset_library_repository import (
|
|
||||||
SQLAlchemyAssetLibraryRepository,
|
|
||||||
)
|
|
||||||
from packages.adapters.sqlalchemy_impl.asset_repository import (
|
|
||||||
SQLAlchemyAssetRepository,
|
|
||||||
)
|
|
||||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
|
||||||
SQLAlchemyGenerationTaskRepository,
|
|
||||||
)
|
|
||||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
|
||||||
SQLAlchemyTemplateClipConfigRepository,
|
|
||||||
)
|
|
||||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
|
||||||
SQLAlchemyTemplateRepository,
|
|
||||||
)
|
|
||||||
from packages.application.generation_tasks import (
|
|
||||||
CreateGenerationTaskCommand,
|
|
||||||
CreateGenerationTaskUseCase,
|
|
||||||
)
|
|
||||||
from packages.domain.config_schemas import normalize_plan_config
|
from packages.domain.config_schemas import normalize_plan_config
|
||||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||||
|
|
||||||
|
from ._helpers import check_project_access
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -166,6 +146,7 @@ class AIRecommendClipItem(BaseModel):
|
|||||||
text_content: str = Field(default="", description="文字内容")
|
text_content: str = Field(default="", description="文字内容")
|
||||||
duration: float = Field(..., ge=0.0, description="片段时长(秒)")
|
duration: float = Field(..., ge=0.0, description="片段时长(秒)")
|
||||||
transition_effect: str = Field(default="cut", description="转场效果")
|
transition_effect: str = Field(default="cut", description="转场效果")
|
||||||
|
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长(秒),0 表示使用默认值")
|
||||||
asset_id: str = Field(default="", description="关联素材 ID")
|
asset_id: str = Field(default="", description="关联素材 ID")
|
||||||
start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)")
|
start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)")
|
||||||
config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置")
|
config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置")
|
||||||
@@ -229,6 +210,8 @@ class _PlanClipItem(BaseModel):
|
|||||||
start_time: float
|
start_time: float
|
||||||
duration: float
|
duration: float
|
||||||
transition_effect: str
|
transition_effect: str
|
||||||
|
transition_duration: float
|
||||||
|
playback_speed: float = 1.0
|
||||||
status: str
|
status: str
|
||||||
config: Optional[dict[str, Any]] = None
|
config: Optional[dict[str, Any]] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -247,17 +230,6 @@ class GenerateFromTemplateResponse(BaseModel):
|
|||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _check_project_access(project_id: str, user_id: str, project_repository: Any) -> None:
|
|
||||||
"""校验用户对项目的访问权限(参照 assets.py 的 can_access 模式)"""
|
|
||||||
if not project_id or not project_id.strip():
|
|
||||||
return
|
|
||||||
project = project_repository.find_by_id(project_id)
|
|
||||||
if project is None:
|
|
||||||
raise HTTPException(status_code=404, detail="项目不存在")
|
|
||||||
if not project.can_access(user_id):
|
|
||||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
|
||||||
|
|
||||||
|
|
||||||
def _to_response(p: EditPlan) -> EditPlanResponse:
|
def _to_response(p: EditPlan) -> EditPlanResponse:
|
||||||
return EditPlanResponse(
|
return EditPlanResponse(
|
||||||
id=p.id,
|
id=p.id,
|
||||||
@@ -273,7 +245,7 @@ def _to_response(p: EditPlan) -> EditPlanResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
# ── CRUD Routes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=EditPlanListResponse)
|
@router.get("", response_model=EditPlanListResponse)
|
||||||
@@ -311,7 +283,7 @@ def list_plans(
|
|||||||
|
|
||||||
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
|
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
|
||||||
if project_id:
|
if project_id:
|
||||||
_check_project_access(project_id, current_user.user.id, project_repository)
|
check_project_access(project_id, current_user.user.id, project_repository)
|
||||||
|
|
||||||
skip = (page - 1) * page_size
|
skip = (page - 1) * page_size
|
||||||
plans = svc.list_plans(
|
plans = svc.list_plans(
|
||||||
@@ -353,7 +325,7 @@ def get_plan(
|
|||||||
)
|
)
|
||||||
# 项目鉴权
|
# 项目鉴权
|
||||||
if plan.project_id:
|
if plan.project_id:
|
||||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||||
return _to_response(plan)
|
return _to_response(plan)
|
||||||
|
|
||||||
|
|
||||||
@@ -369,7 +341,7 @@ def create_plan(
|
|||||||
project_id = (body.project_id or "").strip()
|
project_id = (body.project_id or "").strip()
|
||||||
# 项目鉴权
|
# 项目鉴权
|
||||||
if project_id:
|
if project_id:
|
||||||
_check_project_access(project_id, current_user.user.id, project_repository)
|
check_project_access(project_id, current_user.user.id, project_repository)
|
||||||
svc = EditPlanService(db)
|
svc = EditPlanService(db)
|
||||||
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
|
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
|
||||||
normalized_config = normalize_plan_config(body.config)
|
normalized_config = normalize_plan_config(body.config)
|
||||||
@@ -411,7 +383,7 @@ def update_plan(
|
|||||||
if existing is None:
|
if existing is None:
|
||||||
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||||||
if existing.project_id:
|
if existing.project_id:
|
||||||
_check_project_access(existing.project_id, current_user.user.id, project_repository)
|
check_project_access(existing.project_id, current_user.user.id, project_repository)
|
||||||
|
|
||||||
# 基础字段更新
|
# 基础字段更新
|
||||||
try:
|
try:
|
||||||
@@ -453,7 +425,7 @@ def update_plan(
|
|||||||
return _to_response(result)
|
return _to_response(result)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||||
def delete_plan(
|
def delete_plan(
|
||||||
plan_id: str,
|
plan_id: str,
|
||||||
db: Session = Depends(get_db_session),
|
db: Session = Depends(get_db_session),
|
||||||
@@ -465,7 +437,7 @@ def delete_plan(
|
|||||||
# 项目鉴权
|
# 项目鉴权
|
||||||
existing = svc.get_plan(plan_id)
|
existing = svc.get_plan(plan_id)
|
||||||
if existing and existing.project_id:
|
if existing and existing.project_id:
|
||||||
_check_project_access(existing.project_id, current_user.user.id, project_repository)
|
check_project_access(existing.project_id, current_user.user.id, project_repository)
|
||||||
deleted = svc.delete_plan(plan_id)
|
deleted = svc.delete_plan(plan_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -479,753 +451,12 @@ def delete_plan(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── 生成相关端点(任务 2.05) ─────────────────────────────────────────────────
|
# ── Include sub-routers (拆分模块) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
from .edit_plans_ai import router as ai_router
|
||||||
|
from .edit_plans_generation import router as generation_router
|
||||||
|
from .edit_plans_timeline import router as timeline_router
|
||||||
|
|
||||||
@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse)
|
router.include_router(generation_router)
|
||||||
def generate_plan(
|
router.include_router(ai_router)
|
||||||
plan_id: str,
|
router.include_router(timeline_router)
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
project_repository: Any = Depends(get_project_repository),
|
|
||||||
asset_library_repo: Any = Depends(get_asset_library_repository),
|
|
||||||
asset_repo: Any = Depends(get_asset_repository),
|
|
||||||
) -> EditPlanGenerateResponse:
|
|
||||||
"""触发剪辑计划渲染生成
|
|
||||||
|
|
||||||
前置条件:计划状态必须为 editing,且至少有一个片段。
|
|
||||||
流程:
|
|
||||||
1. 验证计划状态为 editing
|
|
||||||
2. 将 pending 片段标记为 ready
|
|
||||||
3. 创建 GenerationTask
|
|
||||||
4. 调度 Celery 任务 worker.render_edit_plan
|
|
||||||
5. 将计划状态流转为 rendering
|
|
||||||
"""
|
|
||||||
svc = EditPlanService(db)
|
|
||||||
# 项目鉴权
|
|
||||||
plan_check = svc.get_plan(plan_id)
|
|
||||||
if plan_check is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
|
||||||
if plan_check.project_id:
|
|
||||||
_check_project_access(plan_check.project_id, current_user.user.id, project_repository)
|
|
||||||
|
|
||||||
# ── 自动兜底 1: draft → editing ──────────────────────────────────────
|
|
||||||
if plan_check.status == EditPlanStatus.DRAFT:
|
|
||||||
logger.info("自动兜底: plan=%s draft→editing", plan_id)
|
|
||||||
svc.transition_status(plan_id, EditPlanStatus.EDITING)
|
|
||||||
|
|
||||||
# ── 自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置 ──────────
|
|
||||||
existing_clips = svc.count_clips(plan_id)
|
|
||||||
if existing_clips == 0 and plan_check.template_id:
|
|
||||||
logger.info(
|
|
||||||
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
|
|
||||||
plan_id,
|
|
||||||
plan_check.template_id,
|
|
||||||
)
|
|
||||||
# 优先从新模型 template_clip_configs 读取,若无则回退到旧模型 template_segments
|
|
||||||
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
|
||||||
configs = clip_config_repo.list_by_template(plan_check.template_id)
|
|
||||||
if configs:
|
|
||||||
for cfg in configs:
|
|
||||||
svc.create_clip(
|
|
||||||
plan_id=plan_id,
|
|
||||||
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
|
||||||
order=cfg.order,
|
|
||||||
template_clip_config_id=cfg.id,
|
|
||||||
duration=cfg.default_duration,
|
|
||||||
transition_effect=(
|
|
||||||
cfg.transition_effect.value
|
|
||||||
if hasattr(cfg.transition_effect, "value")
|
|
||||||
else cfg.transition_effect
|
|
||||||
),
|
|
||||||
)
|
|
||||||
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
|
|
||||||
else:
|
|
||||||
# 回退到旧模型 template_segments
|
|
||||||
tpl_repo = SQLAlchemyTemplateRepository(db)
|
|
||||||
segments = tpl_repo.list_segments(plan_check.template_id)
|
|
||||||
for seg in segments:
|
|
||||||
avg_duration = (seg.duration_min + seg.duration_max) / 2
|
|
||||||
svc.create_clip(
|
|
||||||
plan_id=plan_id,
|
|
||||||
clip_type="main", # 旧模型无结构角色,统一为主体片段
|
|
||||||
order=seg.segment_order,
|
|
||||||
duration=avg_duration,
|
|
||||||
config={
|
|
||||||
"material_type": seg.material_type or "",
|
|
||||||
"template_segment_id": seg.id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
|
|
||||||
|
|
||||||
# ── 自动兜底 3: 为没有素材的片段分配素材 ──────────────────────────
|
|
||||||
# 如果 plan.config.asset_ids 有素材,但 clips 没有 asset_id,自动按顺序分配
|
|
||||||
all_clips = svc.list_clips(plan_id)
|
|
||||||
clips_without_asset = [c for c in all_clips if not c.asset_id]
|
|
||||||
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
|
|
||||||
material_mode = (plan_check.config or {}).get("material_mode", "manual")
|
|
||||||
|
|
||||||
if clips_without_asset and config_asset_ids:
|
|
||||||
logger.info(
|
|
||||||
"自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
|
|
||||||
plan_id,
|
|
||||||
len(clips_without_asset),
|
|
||||||
len(config_asset_ids),
|
|
||||||
)
|
|
||||||
for i, clip in enumerate(clips_without_asset):
|
|
||||||
asset_idx = i % len(config_asset_ids)
|
|
||||||
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
|
||||||
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
|
|
||||||
clips_without_asset = [] # 已分配完
|
|
||||||
|
|
||||||
# ── 自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取 ────────────
|
|
||||||
if clips_without_asset and material_mode == "auto" and plan_check.project_id:
|
|
||||||
import random
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)",
|
|
||||||
plan_id,
|
|
||||||
len(clips_without_asset),
|
|
||||||
)
|
|
||||||
# 找到项目的视频素材库
|
|
||||||
libs = asset_library_repo.find_by_project(plan_check.project_id)
|
|
||||||
video_lib = None
|
|
||||||
for lib in libs:
|
|
||||||
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
|
||||||
if lib_kind == "video":
|
|
||||||
video_lib = lib
|
|
||||||
break
|
|
||||||
|
|
||||||
if video_lib:
|
|
||||||
assets = asset_repo.find_by_library(video_lib.id)
|
|
||||||
# 筛选 ready 状态的视频素材
|
|
||||||
ready_videos = [
|
|
||||||
a
|
|
||||||
for a in assets
|
|
||||||
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
|
||||||
and a.mime_type
|
|
||||||
and a.mime_type.startswith("video")
|
|
||||||
]
|
|
||||||
if ready_videos:
|
|
||||||
# 随机选取,按片段数轮询分配
|
|
||||||
random.shuffle(ready_videos)
|
|
||||||
for i, clip in enumerate(clips_without_asset):
|
|
||||||
asset = ready_videos[i % len(ready_videos)]
|
|
||||||
svc.assign_asset(clip.id, asset.id)
|
|
||||||
logger.info(
|
|
||||||
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
|
|
||||||
plan_id,
|
|
||||||
video_lib.name,
|
|
||||||
len(ready_videos),
|
|
||||||
len(clips_without_asset),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
|
|
||||||
else:
|
|
||||||
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
|
|
||||||
|
|
||||||
# 检查是否可生成
|
|
||||||
try:
|
|
||||||
can_gen, reason = svc.can_generate(plan_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=str(exc),
|
|
||||||
)
|
|
||||||
if not can_gen:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=reason,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 核心生成流程:捕获异常返回明确错误信息,避免裸 500
|
|
||||||
try:
|
|
||||||
# 将 pending 片段标记为 ready
|
|
||||||
clip_count = svc.mark_clips_ready(plan_id)
|
|
||||||
|
|
||||||
# 创建 GenerationTask
|
|
||||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
|
||||||
|
|
||||||
# 队列限流预检查(repository 不支持计数时跳过)
|
|
||||||
user_id = current_user.user.id
|
|
||||||
try:
|
|
||||||
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
|
|
||||||
gen_task_repo, "count_pending_total"
|
|
||||||
)
|
|
||||||
if has_count:
|
|
||||||
user_pending = gen_task_repo.count_pending_by_user(user_id)
|
|
||||||
global_pending = gen_task_repo.count_pending_total()
|
|
||||||
if user_pending >= USER_PENDING_LIMIT:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=429,
|
|
||||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
|
||||||
)
|
|
||||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="系统繁忙,请稍后再试",
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
|
|
||||||
|
|
||||||
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
|
||||||
plan = svc.get_plan_or_raise(plan_id)
|
|
||||||
gen_task = gen_task_use_case.execute(
|
|
||||||
CreateGenerationTaskCommand(
|
|
||||||
project_id="",
|
|
||||||
template_id=plan.template_id,
|
|
||||||
created_by_user_id=current_user.user.id,
|
|
||||||
source_edit_plan_id=plan_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 将 generation_task_id 存入 plan config
|
|
||||||
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
|
|
||||||
|
|
||||||
# 流转状态为 rendering
|
|
||||||
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
|
|
||||||
|
|
||||||
# 调度 Celery 任务
|
|
||||||
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
|
|
||||||
|
|
||||||
# 获取最新状态
|
|
||||||
updated_plan = svc.get_plan_or_raise(plan_id)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
|
|
||||||
plan_id,
|
|
||||||
gen_task.id,
|
|
||||||
clip_count,
|
|
||||||
current_user.user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return EditPlanGenerateResponse(
|
|
||||||
plan_id=plan_id,
|
|
||||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
|
||||||
generation_task_id=gen_task.id,
|
|
||||||
clip_count=clip_count,
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
# 已处理的 HTTP 异常直接透传
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
|
|
||||||
# 尝试将计划标记为失败(RENDERING → FAILED 是合法的状态流转)
|
|
||||||
try:
|
|
||||||
svc.transition_status(plan_id, EditPlanStatus.FAILED)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="生成失败,请稍后重试",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{plan_id}/generation-status",
|
|
||||||
response_model=EditPlanGenerationStatusResponse,
|
|
||||||
)
|
|
||||||
def get_generation_status(
|
|
||||||
plan_id: str,
|
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
project_repository: Any = Depends(get_project_repository),
|
|
||||||
) -> EditPlanGenerationStatusResponse:
|
|
||||||
"""查询剪辑计划生成进度
|
|
||||||
|
|
||||||
返回计划状态、关联的 GenerationTask ID、以及每个片段的状态。
|
|
||||||
"""
|
|
||||||
svc = EditPlanService(db)
|
|
||||||
try:
|
|
||||||
gen_status = svc.get_generation_status(plan_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=str(exc),
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = gen_status["plan"]
|
|
||||||
# 项目鉴权
|
|
||||||
if plan.project_id:
|
|
||||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
|
||||||
clips = gen_status["clips"]
|
|
||||||
|
|
||||||
clip_items = [
|
|
||||||
ClipStatusItem(
|
|
||||||
clip_id=c.id,
|
|
||||||
clip_type=c.clip_type,
|
|
||||||
order=c.order,
|
|
||||||
status=c.status.value if hasattr(c.status, "value") else c.status,
|
|
||||||
asset_id=c.asset_id or "",
|
|
||||||
text_content=c.text_content or "",
|
|
||||||
duration=c.duration,
|
|
||||||
)
|
|
||||||
for c in clips
|
|
||||||
]
|
|
||||||
|
|
||||||
return EditPlanGenerationStatusResponse(
|
|
||||||
plan_id=plan_id,
|
|
||||||
plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status,
|
|
||||||
generation_task_id=gen_status["generation_task_id"],
|
|
||||||
clips=clip_items,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{plan_id}/generations",
|
|
||||||
response_model=EditPlanGenerationsResponse,
|
|
||||||
)
|
|
||||||
def list_plan_generations(
|
|
||||||
plan_id: str,
|
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
project_repository: Any = Depends(get_project_repository),
|
|
||||||
) -> EditPlanGenerationsResponse:
|
|
||||||
"""查询剪辑计划关联的所有生成记录
|
|
||||||
|
|
||||||
返回该剪辑计划触发的所有 GenerationTask,按创建时间倒序。
|
|
||||||
"""
|
|
||||||
svc = EditPlanService(db)
|
|
||||||
# 验证计划存在 + 项目鉴权
|
|
||||||
plan = svc.get_plan_or_raise(plan_id)
|
|
||||||
if plan.project_id:
|
|
||||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
|
||||||
|
|
||||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
|
||||||
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
|
||||||
items = [
|
|
||||||
GenerationTaskResponse(
|
|
||||||
id=t.id,
|
|
||||||
project_id=t.project_id,
|
|
||||||
asset_library_id=t.asset_library_id,
|
|
||||||
strategy_id=t.strategy_id,
|
|
||||||
voice_library_id=t.voice_library_id,
|
|
||||||
template_id=t.template_id,
|
|
||||||
asset_ids=t.asset_ids,
|
|
||||||
title_ids=t.title_ids,
|
|
||||||
voice_ids=t.voice_ids,
|
|
||||||
source_edit_plan_id=t.source_edit_plan_id or "",
|
|
||||||
status=t.status.value if hasattr(t.status, "value") else t.status,
|
|
||||||
progress=t.progress,
|
|
||||||
result_count=t.result_count,
|
|
||||||
error_message=t.error_message,
|
|
||||||
)
|
|
||||||
for t in tasks
|
|
||||||
]
|
|
||||||
return EditPlanGenerationsResponse(items=items, total=len(items))
|
|
||||||
|
|
||||||
|
|
||||||
# ── AI 推荐 & 封面生成端点(任务 3.09) ────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/{plan_id}/ai-recommend",
|
|
||||||
response_model=AIRecommendResponse,
|
|
||||||
)
|
|
||||||
def ai_recommend_clips(
|
|
||||||
plan_id: str,
|
|
||||||
body: AIRecommendRequest,
|
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
project_repository: Any = Depends(get_project_repository),
|
|
||||||
) -> AIRecommendResponse:
|
|
||||||
"""AI 推荐片段方案
|
|
||||||
|
|
||||||
调用 AI 服务分析素材,自动生成片段编排方案并写入剪辑计划。
|
|
||||||
|
|
||||||
流程:
|
|
||||||
1. 验证计划存在且状态为 draft/editing
|
|
||||||
2. 调用 AI 推荐服务(当前为 stub,后续接入真实 AI)
|
|
||||||
3. 清除计划现有片段,按推荐方案重新创建
|
|
||||||
4. 更新计划 config(cover/title/subtitle/bgm)和 total_duration
|
|
||||||
5. 返回推荐方案详情
|
|
||||||
|
|
||||||
前端对接:
|
|
||||||
- 请求体只需传 asset_ids(必填),editing_mode 和 target_duration 可选
|
|
||||||
- 返回的 clips 可直接渲染到时间线
|
|
||||||
- 返回的 config 包含推荐的封面/标题/字幕/BGM 配置
|
|
||||||
"""
|
|
||||||
svc = EditPlanService(db)
|
|
||||||
|
|
||||||
# 验证计划存在
|
|
||||||
try:
|
|
||||||
plan = svc.get_plan_or_raise(plan_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=str(exc),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 项目鉴权
|
|
||||||
if plan.project_id:
|
|
||||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
|
||||||
|
|
||||||
# 验证状态:只允许 draft 或 editing
|
|
||||||
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
|
|
||||||
if plan_status not in ("draft", "editing"):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 调用 AI 推荐服务(同步调用 stub,后续改为 Celery 异步)
|
|
||||||
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
|
|
||||||
|
|
||||||
result = run_ai_recommend(
|
|
||||||
plan_id=plan_id,
|
|
||||||
template_id=plan.template_id,
|
|
||||||
asset_ids=body.asset_ids,
|
|
||||||
editing_mode=body.editing_mode,
|
|
||||||
target_duration=body.target_duration,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中 ──
|
|
||||||
# TODO: 当前各 repo 方法内部 commit(),无法真正回滚。
|
|
||||||
# 后续重构 repo 为 flush() 模式后,此处改为统一 commit。
|
|
||||||
try:
|
|
||||||
# 清除现有片段
|
|
||||||
svc.delete_all_clips(plan_id)
|
|
||||||
|
|
||||||
# 按推荐方案创建新片段
|
|
||||||
for clip_data in result["clips"]:
|
|
||||||
svc.create_clip(
|
|
||||||
plan_id=plan_id,
|
|
||||||
clip_type=clip_data["clip_type"],
|
|
||||||
order=clip_data["order"],
|
|
||||||
text_content=clip_data.get("text_content", ""),
|
|
||||||
duration=clip_data["duration"],
|
|
||||||
transition_effect=clip_data.get("transition_effect", "cut"),
|
|
||||||
asset_id=clip_data.get("asset_id", ""),
|
|
||||||
start_time=clip_data.get("start_time", 0.0),
|
|
||||||
config=clip_data.get("config", {}),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 更新计划 config 和 total_duration
|
|
||||||
normalized_config = normalize_plan_config(result.get("config", {}))
|
|
||||||
svc.update_plan(
|
|
||||||
plan_id,
|
|
||||||
config=normalized_config,
|
|
||||||
total_duration=result["total_duration"],
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
|
|
||||||
# 尝试回滚未提交的变更
|
|
||||||
try:
|
|
||||||
db.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="AI推荐结果保存失败,请稍后重试",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
|
|
||||||
plan_id,
|
|
||||||
len(result["clips"]),
|
|
||||||
result["total_duration"],
|
|
||||||
current_user.user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return AIRecommendResponse(
|
|
||||||
plan_id=plan_id,
|
|
||||||
clips=[
|
|
||||||
AIRecommendClipItem(
|
|
||||||
clip_type=c["clip_type"],
|
|
||||||
order=c["order"],
|
|
||||||
text_content=c.get("text_content", ""),
|
|
||||||
duration=c["duration"],
|
|
||||||
transition_effect=c.get("transition_effect", "cut"),
|
|
||||||
asset_id=c.get("asset_id", ""),
|
|
||||||
start_time=c.get("start_time", 0.0),
|
|
||||||
config=c.get("config", {}),
|
|
||||||
)
|
|
||||||
for c in result["clips"]
|
|
||||||
],
|
|
||||||
config=normalized_config,
|
|
||||||
total_duration=result["total_duration"],
|
|
||||||
confidence=result["confidence"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/{plan_id}/generate-cover",
|
|
||||||
response_model=GenerateCoverResponse,
|
|
||||||
)
|
|
||||||
def generate_cover(
|
|
||||||
plan_id: str,
|
|
||||||
body: GenerateCoverRequest,
|
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
project_repository: Any = Depends(get_project_repository),
|
|
||||||
) -> GenerateCoverResponse:
|
|
||||||
"""AI 生成封面
|
|
||||||
|
|
||||||
调用 AI 服务从视频中选帧或生成封面图,并更新计划 config.cover。
|
|
||||||
|
|
||||||
流程:
|
|
||||||
1. 验证计划存在
|
|
||||||
2. 调用 AI 封面生成服务(当前为 stub,后续接入真实 AI)
|
|
||||||
3. 更新 plan.config["cover"] 为生成的封面数据
|
|
||||||
4. 返回封面数据
|
|
||||||
|
|
||||||
前端对接:
|
|
||||||
- cover_type=ai_frame: AI 智能选帧(默认)
|
|
||||||
- cover_type=manual: 手动选帧,需传 frame_time
|
|
||||||
- cover_type=upload: 用户上传,接口返回空 image_url,前端自行上传后更新
|
|
||||||
- cover_type=ai_regenerate: AI 重新生成
|
|
||||||
"""
|
|
||||||
svc = EditPlanService(db)
|
|
||||||
|
|
||||||
# 验证计划存在
|
|
||||||
try:
|
|
||||||
plan = svc.get_plan_or_raise(plan_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=str(exc),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 项目鉴权
|
|
||||||
if plan.project_id:
|
|
||||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
|
||||||
|
|
||||||
# 调用 AI 封面生成服务
|
|
||||||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
|
||||||
|
|
||||||
cover_data = run_generate_cover(
|
|
||||||
plan_id=plan_id,
|
|
||||||
asset_ids=body.asset_ids,
|
|
||||||
cover_type=body.cover_type,
|
|
||||||
frame_time=body.frame_time,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 更新 plan.config["cover"]
|
|
||||||
current_config = dict(plan.config)
|
|
||||||
current_config["cover"] = cover_data
|
|
||||||
normalized = normalize_plan_config(current_config)
|
|
||||||
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"AI 封面生成: plan_id=%s type=%s by user=%s",
|
|
||||||
plan_id,
|
|
||||||
body.cover_type,
|
|
||||||
current_user.user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return GenerateCoverResponse(
|
|
||||||
plan_id=plan_id,
|
|
||||||
cover=cover_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Timeline / Scene 端点(P2-6) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class TimelineSceneResponse(BaseModel):
|
|
||||||
"""时间线场景"""
|
|
||||||
|
|
||||||
scene: str = Field(..., description="场景描述")
|
|
||||||
time: str = Field(..., description='时间范围,如 "0:00 - 0:05"')
|
|
||||||
duration: float = Field(..., ge=0, description="时长(秒)")
|
|
||||||
color: str = Field(..., description="展示颜色")
|
|
||||||
clip_id: str = Field(default="", description="关联的片段 ID")
|
|
||||||
clip_type: str = Field(default="", description="片段类型")
|
|
||||||
|
|
||||||
|
|
||||||
class TimelineResponse(BaseModel):
|
|
||||||
"""时间线响应"""
|
|
||||||
|
|
||||||
plan_id: str
|
|
||||||
total_duration: float
|
|
||||||
scenes: List[TimelineSceneResponse]
|
|
||||||
|
|
||||||
|
|
||||||
# clip_type → 颜色映射
|
|
||||||
_CLIP_TYPE_COLORS = {
|
|
||||||
"intro": "#6366f1",
|
|
||||||
"title": "#6366f1",
|
|
||||||
"product": "#818cf8",
|
|
||||||
"showcase": "#10b981",
|
|
||||||
"scene": "#10b981",
|
|
||||||
"subtitle": "#f59e0b",
|
|
||||||
"text": "#f59e0b",
|
|
||||||
"cta": "#ef4444",
|
|
||||||
"outro": "#ef4444",
|
|
||||||
"voiceover": "#8b5cf6",
|
|
||||||
"transition": "#64748b",
|
|
||||||
}
|
|
||||||
|
|
||||||
_DEFAULT_COLOR = "#6366f1"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_time(seconds: float) -> str:
|
|
||||||
"""将秒数格式化为 M:SS"""
|
|
||||||
m = int(seconds) // 60
|
|
||||||
s = int(seconds) % 60
|
|
||||||
return f"{m}:{s:02d}"
|
|
||||||
|
|
||||||
|
|
||||||
def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str:
|
|
||||||
"""根据 clip_type 和 text_content 生成场景描述"""
|
|
||||||
type_labels = {
|
|
||||||
"intro": "开场",
|
|
||||||
"title": "标题",
|
|
||||||
"product": "产品展示",
|
|
||||||
"showcase": "场景展示",
|
|
||||||
"scene": "场景",
|
|
||||||
"subtitle": "字幕",
|
|
||||||
"text": "文字",
|
|
||||||
"cta": "结尾 CTA",
|
|
||||||
"outro": "结尾",
|
|
||||||
"voiceover": "配音",
|
|
||||||
"transition": "转场",
|
|
||||||
}
|
|
||||||
label = type_labels.get(clip_type, clip_type or "片段")
|
|
||||||
if text_content:
|
|
||||||
# 截取前 20 个字符作为副标题
|
|
||||||
short = text_content[:20].strip()
|
|
||||||
if short:
|
|
||||||
return f"{label} - {short}"
|
|
||||||
return label
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{plan_id}/timeline",
|
|
||||||
response_model=TimelineResponse,
|
|
||||||
)
|
|
||||||
def get_plan_timeline(
|
|
||||||
plan_id: str,
|
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
project_repository: Any = Depends(get_project_repository),
|
|
||||||
) -> TimelineResponse:
|
|
||||||
"""获取剪辑计划的时间线场景数据
|
|
||||||
|
|
||||||
返回按计划片段排序的时间线场景列表,供前端 GeneratePage 渲染使用。
|
|
||||||
"""
|
|
||||||
svc = EditPlanService(db)
|
|
||||||
plan = svc.get_plan_or_raise(plan_id)
|
|
||||||
# 项目鉴权
|
|
||||||
if plan.project_id:
|
|
||||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
|
||||||
|
|
||||||
clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200)
|
|
||||||
# 按 order 排序
|
|
||||||
clips.sort(key=lambda c: c.order)
|
|
||||||
|
|
||||||
scenes: List[TimelineSceneResponse] = []
|
|
||||||
current_time = 0.0
|
|
||||||
|
|
||||||
for clip in clips:
|
|
||||||
start = current_time
|
|
||||||
end = start + clip.duration
|
|
||||||
color = _CLIP_TYPE_COLORS.get(clip.clip_type, _DEFAULT_COLOR)
|
|
||||||
scene_label = _clip_type_to_scene_label(clip.clip_type, clip.text_content)
|
|
||||||
|
|
||||||
scenes.append(
|
|
||||||
TimelineSceneResponse(
|
|
||||||
scene=scene_label,
|
|
||||||
time=f"{_format_time(start)} - {_format_time(end)}",
|
|
||||||
duration=clip.duration,
|
|
||||||
color=color,
|
|
||||||
clip_id=clip.id,
|
|
||||||
clip_type=clip.clip_type,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
current_time = end
|
|
||||||
|
|
||||||
total_duration = sum(s.duration for s in scenes) or plan.total_duration
|
|
||||||
|
|
||||||
return TimelineResponse(
|
|
||||||
plan_id=plan_id,
|
|
||||||
total_duration=total_duration,
|
|
||||||
scenes=scenes,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 基于模板生成剪辑计划 ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/generate-from-template",
|
|
||||||
response_model=GenerateFromTemplateResponse,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
def generate_from_template(
|
|
||||||
body: GenerateFromTemplateRequest,
|
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
project_repository: Any = Depends(get_project_repository),
|
|
||||||
) -> GenerateFromTemplateResponse:
|
|
||||||
"""基于模板 + 素材自动生成剪辑计划
|
|
||||||
|
|
||||||
流程:
|
|
||||||
1. 获取模板及其片段配置
|
|
||||||
2. 调用 PlanGeneratorService 生成 EditPlan + EditPlanClips
|
|
||||||
3. 返回完整的计划和片段列表
|
|
||||||
"""
|
|
||||||
from app.services import EditTemplateService
|
|
||||||
|
|
||||||
# 项目鉴权
|
|
||||||
if body.project_id:
|
|
||||||
_check_project_access(body.project_id, current_user.user.id, project_repository)
|
|
||||||
|
|
||||||
template_svc = EditTemplateService(db)
|
|
||||||
|
|
||||||
# 获取模板
|
|
||||||
try:
|
|
||||||
template = template_svc.get_template_or_raise(body.template_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=str(exc),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 获取模板片段配置
|
|
||||||
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
|
|
||||||
|
|
||||||
# 调用 PlanGeneratorService 生成计划
|
|
||||||
generator = PlanGeneratorService(db)
|
|
||||||
result = generator.generate_from_template(
|
|
||||||
template=template,
|
|
||||||
clip_configs=clip_configs,
|
|
||||||
asset_ids=body.asset_ids,
|
|
||||||
project_id=body.project_id,
|
|
||||||
created_by_user_id=current_user.user.id,
|
|
||||||
name=body.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = result["plan"]
|
|
||||||
clips = result["clips"]
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%s",
|
|
||||||
plan.id,
|
|
||||||
body.template_id,
|
|
||||||
len(clips),
|
|
||||||
current_user.user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return GenerateFromTemplateResponse(
|
|
||||||
plan=_to_response(plan),
|
|
||||||
clips=[
|
|
||||||
_PlanClipItem(
|
|
||||||
id=c.id,
|
|
||||||
clip_type=c.clip_type,
|
|
||||||
order=c.order,
|
|
||||||
asset_id=c.asset_id,
|
|
||||||
text_content=c.text_content,
|
|
||||||
start_time=c.start_time,
|
|
||||||
duration=c.duration,
|
|
||||||
transition_effect=c.transition_effect,
|
|
||||||
status=c.status.value if hasattr(c.status, "value") else c.status,
|
|
||||||
config=c.config,
|
|
||||||
created_at=c.created_at,
|
|
||||||
updated_at=c.updated_at,
|
|
||||||
)
|
|
||||||
for c in clips
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""剪辑计划 AI 推荐 & 封面生成 API 端点。
|
||||||
|
|
||||||
|
从 edit_plans.py 拆分,包含:
|
||||||
|
- POST /{plan_id}/ai-recommend AI 推荐片段方案
|
||||||
|
- POST /{plan_id}/generate-cover AI 生成封面
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.api.routes._helpers import check_project_access
|
||||||
|
from app.api.routes.edit_plans import (
|
||||||
|
AIRecommendClipItem,
|
||||||
|
AIRecommendRequest,
|
||||||
|
AIRecommendResponse,
|
||||||
|
GenerateCoverRequest,
|
||||||
|
GenerateCoverResponse,
|
||||||
|
)
|
||||||
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
|
from app.dependencies import get_db_session, get_project_repository
|
||||||
|
from app.services import EditPlanService
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from packages.domain.config_schemas import normalize_plan_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{plan_id}/ai-recommend",
|
||||||
|
response_model=AIRecommendResponse,
|
||||||
|
)
|
||||||
|
def ai_recommend_clips(
|
||||||
|
plan_id: str,
|
||||||
|
body: AIRecommendRequest,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> AIRecommendResponse:
|
||||||
|
"""AI 推荐片段方案
|
||||||
|
|
||||||
|
调用 AI 服务分析素材,自动生成片段编排方案并写入剪辑计划。
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. 验证计划存在且状态为 draft/editing
|
||||||
|
2. 调用 AI 推荐服务(当前为 stub,后续接入真实 AI)
|
||||||
|
3. 清除计划现有片段,按推荐方案重新创建
|
||||||
|
4. 更新计划 config(cover/title/subtitle/bgm)和 total_duration
|
||||||
|
5. 返回推荐方案详情
|
||||||
|
"""
|
||||||
|
svc = EditPlanService(db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plan = svc.get_plan_or_raise(plan_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||||
|
|
||||||
|
if plan.project_id:
|
||||||
|
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||||
|
|
||||||
|
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
|
||||||
|
if plan_status not in ("draft", "editing"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
|
||||||
|
)
|
||||||
|
|
||||||
|
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
|
||||||
|
|
||||||
|
result = run_ai_recommend(
|
||||||
|
plan_id=plan_id,
|
||||||
|
template_id=plan.template_id,
|
||||||
|
asset_ids=body.asset_ids,
|
||||||
|
editing_mode=body.editing_mode,
|
||||||
|
target_duration=body.target_duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中
|
||||||
|
try:
|
||||||
|
svc.delete_all_clips(plan_id)
|
||||||
|
|
||||||
|
for clip_data in result["clips"]:
|
||||||
|
svc.create_clip(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type=clip_data["clip_type"],
|
||||||
|
order=clip_data["order"],
|
||||||
|
text_content=clip_data.get("text_content", ""),
|
||||||
|
duration=clip_data["duration"],
|
||||||
|
transition_effect=clip_data.get("transition_effect", "cut"),
|
||||||
|
asset_id=clip_data.get("asset_id", ""),
|
||||||
|
start_time=clip_data.get("start_time", 0.0),
|
||||||
|
config=clip_data.get("config", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_config = normalize_plan_config(result.get("config", {}))
|
||||||
|
svc.update_plan(
|
||||||
|
plan_id,
|
||||||
|
config=normalized_config,
|
||||||
|
total_duration=result["total_duration"],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception as rollback_err:
|
||||||
|
logger.error(
|
||||||
|
"AI 推荐回滚失败,数据库会话可能处于不一致状态: plan_id=%s error=%s",
|
||||||
|
plan_id,
|
||||||
|
rollback_err,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="AI推荐结果保存失败,请稍后重试",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
|
||||||
|
plan_id,
|
||||||
|
len(result["clips"]),
|
||||||
|
result["total_duration"],
|
||||||
|
current_user.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return AIRecommendResponse(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clips=[
|
||||||
|
AIRecommendClipItem(
|
||||||
|
clip_type=c["clip_type"],
|
||||||
|
order=c["order"],
|
||||||
|
text_content=c.get("text_content", ""),
|
||||||
|
duration=c["duration"],
|
||||||
|
transition_effect=c.get("transition_effect", "cut"),
|
||||||
|
asset_id=c.get("asset_id", ""),
|
||||||
|
start_time=c.get("start_time", 0.0),
|
||||||
|
config=c.get("config", {}),
|
||||||
|
)
|
||||||
|
for c in result["clips"]
|
||||||
|
],
|
||||||
|
config=normalized_config,
|
||||||
|
total_duration=result["total_duration"],
|
||||||
|
confidence=result["confidence"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{plan_id}/generate-cover",
|
||||||
|
response_model=GenerateCoverResponse,
|
||||||
|
)
|
||||||
|
def generate_cover(
|
||||||
|
plan_id: str,
|
||||||
|
body: GenerateCoverRequest,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> GenerateCoverResponse:
|
||||||
|
"""AI 生成封面
|
||||||
|
|
||||||
|
调用 AI 服务从视频中选帧或生成封面图,并更新计划 config.cover。
|
||||||
|
"""
|
||||||
|
svc = EditPlanService(db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plan = svc.get_plan_or_raise(plan_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||||
|
|
||||||
|
if plan.project_id:
|
||||||
|
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||||
|
|
||||||
|
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||||||
|
|
||||||
|
cover_data = run_generate_cover(
|
||||||
|
plan_id=plan_id,
|
||||||
|
asset_ids=body.asset_ids,
|
||||||
|
cover_type=body.cover_type,
|
||||||
|
frame_time=body.frame_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
current_config = dict(plan.config)
|
||||||
|
current_config["cover"] = cover_data
|
||||||
|
normalized = normalize_plan_config(current_config)
|
||||||
|
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"AI 封面生成: plan_id=%s type=%s by user=%s",
|
||||||
|
plan_id,
|
||||||
|
body.cover_type,
|
||||||
|
current_user.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return GenerateCoverResponse(
|
||||||
|
plan_id=plan_id,
|
||||||
|
cover=cover_data,
|
||||||
|
)
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"""剪辑计划生成相关 API 端点。
|
||||||
|
|
||||||
|
从 edit_plans.py 拆分,包含:
|
||||||
|
- POST /{plan_id}/generate 触发剪辑渲染生成
|
||||||
|
- GET /{plan_id}/generation-status 查询生成进度
|
||||||
|
- GET /{plan_id}/generations 查询关联的生成记录
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.api.routes._helpers import check_project_access
|
||||||
|
from app.api.routes.edit_plans import (
|
||||||
|
ClipStatusItem,
|
||||||
|
EditPlanGenerateResponse,
|
||||||
|
EditPlanGenerationsResponse,
|
||||||
|
EditPlanGenerationStatusResponse,
|
||||||
|
)
|
||||||
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
|
from app.core.celery_app import celery_app
|
||||||
|
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
|
||||||
|
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
|
||||||
|
from app.services import EditPlanService
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||||
|
SQLAlchemyGenerationTaskRepository,
|
||||||
|
)
|
||||||
|
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||||
|
SQLAlchemyTemplateClipConfigRepository,
|
||||||
|
)
|
||||||
|
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||||
|
SQLAlchemyTemplateRepository,
|
||||||
|
)
|
||||||
|
from packages.application.generation_tasks import (
|
||||||
|
CreateGenerationTaskCommand,
|
||||||
|
CreateGenerationTaskUseCase,
|
||||||
|
)
|
||||||
|
from packages.domain.edit_plan import EditPlanStatus
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
|
||||||
|
"""自动兜底 1: draft → editing"""
|
||||||
|
if plan_check.status == EditPlanStatus.DRAFT:
|
||||||
|
logger.info("自动兜底: plan=%s draft→editing", plan_id)
|
||||||
|
svc.transition_status(plan_id, EditPlanStatus.EDITING)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None:
|
||||||
|
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
|
||||||
|
existing_clips = svc.count_clips(plan_id)
|
||||||
|
if existing_clips == 0 and plan_check.template_id:
|
||||||
|
logger.info(
|
||||||
|
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
|
||||||
|
plan_id,
|
||||||
|
plan_check.template_id,
|
||||||
|
)
|
||||||
|
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||||
|
configs = clip_config_repo.list_by_template(plan_check.template_id)
|
||||||
|
if configs:
|
||||||
|
for cfg in configs:
|
||||||
|
svc.create_clip(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||||||
|
order=cfg.order,
|
||||||
|
template_clip_config_id=cfg.id,
|
||||||
|
duration=cfg.default_duration,
|
||||||
|
transition_effect=(
|
||||||
|
cfg.transition_effect.value
|
||||||
|
if hasattr(cfg.transition_effect, "value")
|
||||||
|
else cfg.transition_effect
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
|
||||||
|
else:
|
||||||
|
tpl_repo = SQLAlchemyTemplateRepository(db)
|
||||||
|
segments = tpl_repo.list_segments(plan_check.template_id)
|
||||||
|
for seg in segments:
|
||||||
|
avg_duration = (seg.duration_min + seg.duration_max) / 2
|
||||||
|
svc.create_clip(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type="main",
|
||||||
|
order=seg.segment_order,
|
||||||
|
duration=avg_duration,
|
||||||
|
config={
|
||||||
|
"material_type": seg.material_type or "",
|
||||||
|
"template_segment_id": seg.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_fallback_assign_assets(
|
||||||
|
svc: EditPlanService,
|
||||||
|
plan_id: str,
|
||||||
|
plan_check,
|
||||||
|
) -> list:
|
||||||
|
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
|
||||||
|
all_clips = svc.list_clips(plan_id)
|
||||||
|
clips_without_asset = [c for c in all_clips if not c.asset_id]
|
||||||
|
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
|
||||||
|
|
||||||
|
if clips_without_asset and config_asset_ids:
|
||||||
|
logger.info(
|
||||||
|
"自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
|
||||||
|
plan_id,
|
||||||
|
len(clips_without_asset),
|
||||||
|
len(config_asset_ids),
|
||||||
|
)
|
||||||
|
for i, clip in enumerate(clips_without_asset):
|
||||||
|
asset_idx = i % len(config_asset_ids)
|
||||||
|
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||||
|
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
|
||||||
|
clips_without_asset = []
|
||||||
|
|
||||||
|
return clips_without_asset
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_fallback_auto_material_mode(
|
||||||
|
svc: EditPlanService,
|
||||||
|
plan_id: str,
|
||||||
|
plan_check,
|
||||||
|
clips_without_asset: list,
|
||||||
|
asset_library_repo: Any,
|
||||||
|
asset_repo: Any,
|
||||||
|
) -> None:
|
||||||
|
"""自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取"""
|
||||||
|
if not clips_without_asset:
|
||||||
|
return
|
||||||
|
material_mode = (plan_check.config or {}).get("material_mode", "manual")
|
||||||
|
if material_mode != "auto" or not plan_check.project_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)",
|
||||||
|
plan_id,
|
||||||
|
len(clips_without_asset),
|
||||||
|
)
|
||||||
|
libs = asset_library_repo.find_by_project(plan_check.project_id)
|
||||||
|
video_lib = None
|
||||||
|
for lib in libs:
|
||||||
|
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||||
|
if lib_kind == "video":
|
||||||
|
video_lib = lib
|
||||||
|
break
|
||||||
|
|
||||||
|
if video_lib:
|
||||||
|
assets = asset_repo.find_by_library(video_lib.id)
|
||||||
|
ready_videos = [
|
||||||
|
a
|
||||||
|
for a in assets
|
||||||
|
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
||||||
|
and a.mime_type
|
||||||
|
and a.mime_type.startswith("video")
|
||||||
|
]
|
||||||
|
if ready_videos:
|
||||||
|
random.shuffle(ready_videos)
|
||||||
|
for i, clip in enumerate(clips_without_asset):
|
||||||
|
asset = ready_videos[i % len(ready_videos)]
|
||||||
|
svc.assign_asset(clip.id, asset.id)
|
||||||
|
logger.info(
|
||||||
|
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
|
||||||
|
plan_id,
|
||||||
|
video_lib.name,
|
||||||
|
len(ready_videos),
|
||||||
|
len(clips_without_asset),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
|
||||||
|
else:
|
||||||
|
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
|
||||||
|
"""队列限流预检查"""
|
||||||
|
try:
|
||||||
|
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
|
||||||
|
gen_task_repo, "count_pending_total"
|
||||||
|
)
|
||||||
|
if has_count:
|
||||||
|
user_pending = gen_task_repo.count_pending_by_user(user_id)
|
||||||
|
global_pending = gen_task_repo.count_pending_total()
|
||||||
|
if user_pending >= USER_PENDING_LIMIT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||||
|
)
|
||||||
|
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="系统繁忙,请稍后再试",
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse)
|
||||||
|
def generate_plan(
|
||||||
|
plan_id: str,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
asset_library_repo: Any = Depends(get_asset_library_repository),
|
||||||
|
asset_repo: Any = Depends(get_asset_repository),
|
||||||
|
) -> EditPlanGenerateResponse:
|
||||||
|
"""触发剪辑计划渲染生成
|
||||||
|
|
||||||
|
前置条件:计划状态必须为 editing,且至少有一个片段。
|
||||||
|
流程:
|
||||||
|
1. 验证计划状态为 editing
|
||||||
|
2. 将 pending 片段标记为 ready
|
||||||
|
3. 创建 GenerationTask
|
||||||
|
4. 调度 Celery 任务 worker.render_edit_plan
|
||||||
|
5. 将计划状态流转为 rendering
|
||||||
|
"""
|
||||||
|
svc = EditPlanService(db)
|
||||||
|
plan_check = svc.get_plan(plan_id)
|
||||||
|
if plan_check is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||||||
|
if plan_check.project_id:
|
||||||
|
check_project_access(plan_check.project_id, current_user.user.id, project_repository)
|
||||||
|
|
||||||
|
# 自动兜底流程
|
||||||
|
_auto_fallback_draft_to_editing(svc, plan_id, plan_check)
|
||||||
|
_auto_fallback_copy_template_clips(svc, plan_id, plan_check, db)
|
||||||
|
clips_without_asset = _auto_fallback_assign_assets(svc, plan_id, plan_check)
|
||||||
|
_auto_fallback_auto_material_mode(svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo)
|
||||||
|
|
||||||
|
# 检查是否可生成
|
||||||
|
try:
|
||||||
|
can_gen, reason = svc.can_generate(plan_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||||
|
if not can_gen:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
|
||||||
|
|
||||||
|
# 核心生成流程
|
||||||
|
try:
|
||||||
|
clip_count = svc.mark_clips_ready(plan_id)
|
||||||
|
|
||||||
|
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||||
|
user_id = current_user.user.id
|
||||||
|
_check_queue_limits(gen_task_repo, user_id)
|
||||||
|
|
||||||
|
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
||||||
|
plan = svc.get_plan_or_raise(plan_id)
|
||||||
|
gen_task = gen_task_use_case.execute(
|
||||||
|
CreateGenerationTaskCommand(
|
||||||
|
project_id="",
|
||||||
|
template_id=plan.template_id,
|
||||||
|
created_by_user_id=current_user.user.id,
|
||||||
|
source_edit_plan_id=plan_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
|
||||||
|
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
|
||||||
|
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
|
||||||
|
|
||||||
|
updated_plan = svc.get_plan_or_raise(plan_id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
|
||||||
|
plan_id,
|
||||||
|
gen_task.id,
|
||||||
|
clip_count,
|
||||||
|
current_user.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return EditPlanGenerateResponse(
|
||||||
|
plan_id=plan_id,
|
||||||
|
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||||
|
generation_task_id=gen_task.id,
|
||||||
|
clip_count=clip_count,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
|
||||||
|
try:
|
||||||
|
svc.transition_status(plan_id, EditPlanStatus.FAILED)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="生成失败,请稍后重试",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{plan_id}/generation-status",
|
||||||
|
response_model=EditPlanGenerationStatusResponse,
|
||||||
|
)
|
||||||
|
def get_generation_status(
|
||||||
|
plan_id: str,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> EditPlanGenerationStatusResponse:
|
||||||
|
"""查询剪辑计划生成进度"""
|
||||||
|
svc = EditPlanService(db)
|
||||||
|
try:
|
||||||
|
gen_status = svc.get_generation_status(plan_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||||
|
|
||||||
|
plan = gen_status["plan"]
|
||||||
|
if plan.project_id:
|
||||||
|
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||||
|
clips = gen_status["clips"]
|
||||||
|
|
||||||
|
clip_items = [
|
||||||
|
ClipStatusItem(
|
||||||
|
clip_id=c.id,
|
||||||
|
clip_type=c.clip_type,
|
||||||
|
order=c.order,
|
||||||
|
status=c.status.value if hasattr(c.status, "value") else c.status,
|
||||||
|
asset_id=c.asset_id or "",
|
||||||
|
text_content=c.text_content or "",
|
||||||
|
duration=c.duration,
|
||||||
|
)
|
||||||
|
for c in clips
|
||||||
|
]
|
||||||
|
|
||||||
|
return EditPlanGenerationStatusResponse(
|
||||||
|
plan_id=plan_id,
|
||||||
|
plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status,
|
||||||
|
generation_task_id=gen_status["generation_task_id"],
|
||||||
|
clips=clip_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{plan_id}/generations",
|
||||||
|
response_model=EditPlanGenerationsResponse,
|
||||||
|
)
|
||||||
|
def list_plan_generations(
|
||||||
|
plan_id: str,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> EditPlanGenerationsResponse:
|
||||||
|
"""查询剪辑计划关联的所有生成记录"""
|
||||||
|
svc = EditPlanService(db)
|
||||||
|
plan = svc.get_plan_or_raise(plan_id)
|
||||||
|
if plan.project_id:
|
||||||
|
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||||
|
|
||||||
|
from app.schemas.generation_task import GenerationTaskResponse
|
||||||
|
|
||||||
|
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||||
|
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||||
|
items = [
|
||||||
|
GenerationTaskResponse(
|
||||||
|
id=t.id,
|
||||||
|
project_id=t.project_id,
|
||||||
|
asset_library_id=t.asset_library_id,
|
||||||
|
strategy_id=t.strategy_id,
|
||||||
|
voice_library_id=t.voice_library_id,
|
||||||
|
template_id=t.template_id,
|
||||||
|
asset_ids=t.asset_ids,
|
||||||
|
title_ids=t.title_ids,
|
||||||
|
voice_ids=t.voice_ids,
|
||||||
|
source_edit_plan_id=t.source_edit_plan_id or "",
|
||||||
|
status=t.status.value if hasattr(t.status, "value") else t.status,
|
||||||
|
progress=t.progress,
|
||||||
|
result_count=t.result_count,
|
||||||
|
error_message=t.error_message,
|
||||||
|
)
|
||||||
|
for t in tasks
|
||||||
|
]
|
||||||
|
return EditPlanGenerationsResponse(items=items, total=len(items))
|
||||||
+221
@@ -0,0 +1,221 @@
|
|||||||
|
"""剪辑计划时间线 & 模板生成 API 端点。
|
||||||
|
|
||||||
|
从 edit_plans.py 拆分,包含:
|
||||||
|
- GET /{plan_id}/timeline 时间线场景数据
|
||||||
|
- POST /generate-from-template 基于模板+素材自动生成剪辑计划
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, List
|
||||||
|
|
||||||
|
from app.api.routes._helpers import check_project_access
|
||||||
|
from app.api.routes.edit_plans import (
|
||||||
|
GenerateFromTemplateRequest,
|
||||||
|
GenerateFromTemplateResponse,
|
||||||
|
_PlanClipItem,
|
||||||
|
_to_response,
|
||||||
|
)
|
||||||
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
|
from app.dependencies import get_db_session, get_project_repository
|
||||||
|
from app.services import EditPlanService, PlanGeneratorService
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Timeline Schemas ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineSceneResponse(BaseModel):
|
||||||
|
"""时间线场景"""
|
||||||
|
|
||||||
|
scene: str = Field(..., description="场景描述")
|
||||||
|
time: str = Field(..., description='时间范围,如 "0:00 - 0:05"')
|
||||||
|
duration: float = Field(..., ge=0, description="时长(秒)")
|
||||||
|
color: str = Field(..., description="展示颜色")
|
||||||
|
clip_id: str = Field(default="", description="关联的片段 ID")
|
||||||
|
clip_type: str = Field(default="", description="片段类型")
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineResponse(BaseModel):
|
||||||
|
"""时间线响应"""
|
||||||
|
|
||||||
|
plan_id: str
|
||||||
|
total_duration: float
|
||||||
|
scenes: List[TimelineSceneResponse]
|
||||||
|
|
||||||
|
|
||||||
|
# clip_type → 颜色映射
|
||||||
|
_CLIP_TYPE_COLORS = {
|
||||||
|
"intro": "#6366f1",
|
||||||
|
"title": "#6366f1",
|
||||||
|
"product": "#818cf8",
|
||||||
|
"showcase": "#10b981",
|
||||||
|
"scene": "#10b981",
|
||||||
|
"subtitle": "#f59e0b",
|
||||||
|
"text": "#f59e0b",
|
||||||
|
"cta": "#ef4444",
|
||||||
|
"outro": "#ef4444",
|
||||||
|
"voiceover": "#8b5cf6",
|
||||||
|
"transition": "#64748b",
|
||||||
|
}
|
||||||
|
|
||||||
|
_DEFAULT_COLOR = "#6366f1"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_time(seconds: float) -> str:
|
||||||
|
"""将秒数格式化为 M:SS"""
|
||||||
|
m = int(seconds) // 60
|
||||||
|
s = int(seconds) % 60
|
||||||
|
return f"{m}:{s:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str:
|
||||||
|
"""根据 clip_type 和 text_content 生成场景描述"""
|
||||||
|
type_labels = {
|
||||||
|
"intro": "开场",
|
||||||
|
"title": "标题",
|
||||||
|
"product": "产品展示",
|
||||||
|
"showcase": "场景展示",
|
||||||
|
"scene": "场景",
|
||||||
|
"subtitle": "字幕",
|
||||||
|
"text": "文字",
|
||||||
|
"cta": "结尾 CTA",
|
||||||
|
"outro": "结尾",
|
||||||
|
"voiceover": "配音",
|
||||||
|
"transition": "转场",
|
||||||
|
}
|
||||||
|
label = type_labels.get(clip_type, clip_type or "片段")
|
||||||
|
if text_content:
|
||||||
|
short = text_content[:20].strip()
|
||||||
|
if short:
|
||||||
|
return f"{label} - {short}"
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{plan_id}/timeline",
|
||||||
|
response_model=TimelineResponse,
|
||||||
|
)
|
||||||
|
def get_plan_timeline(
|
||||||
|
plan_id: str,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> TimelineResponse:
|
||||||
|
"""获取剪辑计划的时间线场景数据"""
|
||||||
|
svc = EditPlanService(db)
|
||||||
|
plan = svc.get_plan_or_raise(plan_id)
|
||||||
|
if plan.project_id:
|
||||||
|
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||||
|
|
||||||
|
clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200)
|
||||||
|
clips.sort(key=lambda c: c.order)
|
||||||
|
|
||||||
|
scenes: List[TimelineSceneResponse] = []
|
||||||
|
current_time = 0.0
|
||||||
|
|
||||||
|
for clip in clips:
|
||||||
|
start = current_time
|
||||||
|
end = start + clip.duration
|
||||||
|
color = _CLIP_TYPE_COLORS.get(clip.clip_type, _DEFAULT_COLOR)
|
||||||
|
scene_label = _clip_type_to_scene_label(clip.clip_type, clip.text_content)
|
||||||
|
|
||||||
|
scenes.append(
|
||||||
|
TimelineSceneResponse(
|
||||||
|
scene=scene_label,
|
||||||
|
time=f"{_format_time(start)} - {_format_time(end)}",
|
||||||
|
duration=clip.duration,
|
||||||
|
color=color,
|
||||||
|
clip_id=clip.id,
|
||||||
|
clip_type=clip.clip_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
current_time = end
|
||||||
|
|
||||||
|
total_duration = sum(s.duration for s in scenes) or plan.total_duration
|
||||||
|
|
||||||
|
return TimelineResponse(
|
||||||
|
plan_id=plan_id,
|
||||||
|
total_duration=total_duration,
|
||||||
|
scenes=scenes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/generate-from-template",
|
||||||
|
response_model=GenerateFromTemplateResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def generate_from_template(
|
||||||
|
body: GenerateFromTemplateRequest,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> GenerateFromTemplateResponse:
|
||||||
|
"""基于模板 + 素材自动生成剪辑计划"""
|
||||||
|
from app.services import EditTemplateService
|
||||||
|
|
||||||
|
if body.project_id:
|
||||||
|
check_project_access(body.project_id, current_user.user.id, project_repository)
|
||||||
|
|
||||||
|
template_svc = EditTemplateService(db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
template = template_svc.get_template_or_raise(body.template_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||||
|
|
||||||
|
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
|
||||||
|
|
||||||
|
generator = PlanGeneratorService(db)
|
||||||
|
result = generator.generate_from_template(
|
||||||
|
template=template,
|
||||||
|
clip_configs=clip_configs,
|
||||||
|
asset_ids=body.asset_ids,
|
||||||
|
project_id=body.project_id,
|
||||||
|
created_by_user_id=current_user.user.id,
|
||||||
|
name=body.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = result["plan"]
|
||||||
|
clips = result["clips"]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%s",
|
||||||
|
plan.id,
|
||||||
|
body.template_id,
|
||||||
|
len(clips),
|
||||||
|
current_user.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return GenerateFromTemplateResponse(
|
||||||
|
plan=_to_response(plan),
|
||||||
|
clips=[
|
||||||
|
_PlanClipItem(
|
||||||
|
id=c.id,
|
||||||
|
clip_type=c.clip_type,
|
||||||
|
order=c.order,
|
||||||
|
asset_id=c.asset_id,
|
||||||
|
text_content=c.text_content,
|
||||||
|
start_time=c.start_time,
|
||||||
|
duration=c.duration,
|
||||||
|
transition_effect=c.transition_effect,
|
||||||
|
transition_duration=c.transition_duration,
|
||||||
|
status=c.status.value if hasattr(c.status, "value") else c.status,
|
||||||
|
config=c.config,
|
||||||
|
created_at=c.created_at,
|
||||||
|
updated_at=c.updated_at,
|
||||||
|
)
|
||||||
|
for c in clips
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
"""模板管理 API — Phase 8 模板编排引擎.
|
|
||||||
|
|
||||||
RESTful CRUD for EditTemplate:
|
|
||||||
- GET /api/v1/edit-templates 列表(分页 + 类型筛选)
|
|
||||||
- GET /api/v1/edit-templates/{id} 详情
|
|
||||||
- POST /api/v1/edit-templates 创建(管理员)
|
|
||||||
- PUT /api/v1/edit-templates/{id} 更新
|
|
||||||
- DELETE /api/v1/edit-templates/{id} 删除(软删除 → inactive)
|
|
||||||
|
|
||||||
业务逻辑委托给 EditTemplateService 服务层。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, List, Optional
|
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
from app.dependencies import get_db_session
|
|
||||||
from app.services import EditTemplateService
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
||||||
from fastapi.responses import Response
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from packages.domain.config_schemas import normalize_template_config
|
|
||||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Pydantic Schemas ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class EditTemplateCreateRequest(BaseModel):
|
|
||||||
"""创建模板请求体"""
|
|
||||||
|
|
||||||
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
|
|
||||||
description: str = Field(default="", max_length=2000, description="模板描述")
|
|
||||||
template_type: str = Field(default="default", max_length=50, description="模板类型")
|
|
||||||
editing_mode: str = Field(
|
|
||||||
default="one_take", max_length=20, description="剪辑模式: one_take/pip/voice_over/voice_pip"
|
|
||||||
)
|
|
||||||
config: dict[str, Any] = Field(default_factory=dict, description="模板配置 (JSON)")
|
|
||||||
preview_url: str = Field(default="", max_length=500, description="预览地址")
|
|
||||||
sort_weight: int = Field(default=0, ge=0, le=9999, description="排序权重")
|
|
||||||
|
|
||||||
|
|
||||||
class EditTemplateUpdateRequest(BaseModel):
|
|
||||||
"""更新模板请求体"""
|
|
||||||
|
|
||||||
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="模板名称")
|
|
||||||
description: Optional[str] = Field(default=None, max_length=2000, description="模板描述")
|
|
||||||
template_type: Optional[str] = Field(default=None, max_length=50, description="模板类型")
|
|
||||||
editing_mode: Optional[str] = Field(
|
|
||||||
default=None, max_length=20, description="剪辑模式: one_take/pip/voice_over/voice_pip"
|
|
||||||
)
|
|
||||||
config: Optional[dict[str, Any]] = Field(default=None, description="模板配置 (JSON)")
|
|
||||||
preview_url: Optional[str] = Field(default=None, max_length=500, description="预览地址")
|
|
||||||
sort_weight: Optional[int] = Field(default=None, ge=0, le=9999, description="排序权重")
|
|
||||||
status: Optional[str] = Field(default=None, description="状态: active / inactive")
|
|
||||||
|
|
||||||
|
|
||||||
class EditTemplateResponse(BaseModel):
|
|
||||||
"""模板响应体"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
template_type: str
|
|
||||||
editing_mode: str
|
|
||||||
config: dict[str, Any]
|
|
||||||
preview_url: str
|
|
||||||
sort_weight: int
|
|
||||||
status: str
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
class EditTemplateListResponse(BaseModel):
|
|
||||||
"""模板列表响应体"""
|
|
||||||
|
|
||||||
items: List[EditTemplateResponse]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _require_admin(current_user: AuthenticatedUser) -> None:
|
|
||||||
"""校验当前用户是否为管理员,非管理员返回 403"""
|
|
||||||
if not getattr(current_user.user, "is_admin", False):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="仅管理员可执行此操作",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_response(t: EditTemplate) -> EditTemplateResponse:
|
|
||||||
return EditTemplateResponse(
|
|
||||||
id=t.id,
|
|
||||||
name=t.name,
|
|
||||||
description=t.description,
|
|
||||||
template_type=t.template_type,
|
|
||||||
editing_mode=t.editing_mode,
|
|
||||||
config=t.config,
|
|
||||||
preview_url=t.preview_url,
|
|
||||||
sort_weight=t.sort_weight,
|
|
||||||
status=t.status.value if hasattr(t.status, "value") else t.status,
|
|
||||||
created_at=t.created_at,
|
|
||||||
updated_at=t.updated_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=EditTemplateListResponse)
|
|
||||||
def list_templates(
|
|
||||||
page: int = Query(default=1, ge=1, description="页码"),
|
|
||||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
|
|
||||||
template_type: Optional[str] = Query(default=None, description="按类型筛选"),
|
|
||||||
status_filter: Optional[str] = Query(
|
|
||||||
default=None,
|
|
||||||
alias="status",
|
|
||||||
description="按状态筛选: active / inactive",
|
|
||||||
),
|
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> EditTemplateListResponse:
|
|
||||||
"""获取模板列表(支持分页、按类型/状态筛选)"""
|
|
||||||
svc = EditTemplateService(db)
|
|
||||||
|
|
||||||
# 解析状态筛选
|
|
||||||
status_enum: Optional[EditTemplateStatus] = None
|
|
||||||
if status_filter:
|
|
||||||
try:
|
|
||||||
status_enum = EditTemplateStatus(status_filter)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"无效的状态值: {status_filter},可选值: active, inactive",
|
|
||||||
)
|
|
||||||
|
|
||||||
skip = (page - 1) * page_size
|
|
||||||
templates = svc.list_templates(
|
|
||||||
template_type=template_type,
|
|
||||||
status=status_enum,
|
|
||||||
skip=skip,
|
|
||||||
limit=page_size,
|
|
||||||
)
|
|
||||||
total = svc.count_templates(
|
|
||||||
template_type=template_type,
|
|
||||||
status=status_enum,
|
|
||||||
)
|
|
||||||
|
|
||||||
return EditTemplateListResponse(
|
|
||||||
items=[_to_response(t) for t in templates],
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{template_id}", response_model=EditTemplateResponse)
|
|
||||||
def get_template(
|
|
||||||
template_id: str,
|
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> EditTemplateResponse:
|
|
||||||
"""获取单个模板详情"""
|
|
||||||
svc = EditTemplateService(db)
|
|
||||||
try:
|
|
||||||
template = svc.get_template_or_raise(template_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=str(exc),
|
|
||||||
)
|
|
||||||
return _to_response(template)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=EditTemplateResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
def create_template(
|
|
||||||
body: EditTemplateCreateRequest,
|
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> EditTemplateResponse:
|
|
||||||
"""创建模板(管理员)"""
|
|
||||||
_require_admin(current_user)
|
|
||||||
svc = EditTemplateService(db)
|
|
||||||
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
|
|
||||||
normalized_config = normalize_template_config(body.config)
|
|
||||||
try:
|
|
||||||
created = svc.create_template(
|
|
||||||
name=body.name,
|
|
||||||
description=body.description,
|
|
||||||
template_type=body.template_type,
|
|
||||||
editing_mode=body.editing_mode,
|
|
||||||
config=normalized_config,
|
|
||||||
preview_url=body.preview_url,
|
|
||||||
sort_weight=body.sort_weight,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=str(exc),
|
|
||||||
)
|
|
||||||
logger.info("创建模板: id=%s name=%s by user=%s", created.id, created.name, current_user.user.id)
|
|
||||||
return _to_response(created)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{template_id}", response_model=EditTemplateResponse)
|
|
||||||
def update_template(
|
|
||||||
template_id: str,
|
|
||||||
body: EditTemplateUpdateRequest,
|
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> EditTemplateResponse:
|
|
||||||
"""更新模板"""
|
|
||||||
_require_admin(current_user)
|
|
||||||
svc = EditTemplateService(db)
|
|
||||||
|
|
||||||
# 解析状态
|
|
||||||
status_enum: Optional[EditTemplateStatus] = None
|
|
||||||
if body.status is not None:
|
|
||||||
try:
|
|
||||||
status_enum = EditTemplateStatus(body.status)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"无效的状态值: {body.status},可选值: active, inactive",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 标准化 config(如果提供了)
|
|
||||||
config_to_update = normalize_template_config(body.config) if body.config is not None else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = svc.update_template(
|
|
||||||
template_id,
|
|
||||||
name=body.name,
|
|
||||||
description=body.description,
|
|
||||||
template_type=body.template_type,
|
|
||||||
editing_mode=body.editing_mode,
|
|
||||||
config=config_to_update,
|
|
||||||
preview_url=body.preview_url,
|
|
||||||
sort_weight=body.sort_weight,
|
|
||||||
status=status_enum,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
err_msg = str(exc)
|
|
||||||
if "不存在" in err_msg:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=err_msg,
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=err_msg,
|
|
||||||
)
|
|
||||||
logger.info("更新模板: id=%s by user=%s", template_id, current_user.user.id)
|
|
||||||
return _to_response(result)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
|
||||||
def delete_template(
|
|
||||||
template_id: str,
|
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> Response:
|
|
||||||
"""删除模板(软删除 → 设为 inactive)"""
|
|
||||||
_require_admin(current_user)
|
|
||||||
svc = EditTemplateService(db)
|
|
||||||
try:
|
|
||||||
svc.deactivate_template(template_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=str(exc),
|
|
||||||
)
|
|
||||||
logger.info("删除模板(软删除): id=%s by user=%s", template_id, current_user.user.id)
|
|
||||||
return Response(status_code=204)
|
|
||||||
Executable
+194
@@ -0,0 +1,194 @@
|
|||||||
|
"""Feature Flag 内部管理接口。
|
||||||
|
|
||||||
|
通过内部 API Key 鉴权,支持查看和修改 Feature Flag 配置。
|
||||||
|
主要用于灰度发布期间的动态开关控制。
|
||||||
|
|
||||||
|
API:
|
||||||
|
GET /api/v1/internal/feature-flags - 列出所有 flag
|
||||||
|
GET /api/v1/internal/feature-flags/{name} - 查看单个 flag
|
||||||
|
PUT /api/v1/internal/feature-flags/{name} - 设置 flag 配置
|
||||||
|
DELETE /api/v1/internal/feature-flags/{name} - 删除 flag
|
||||||
|
|
||||||
|
鉴权:X-API-Key header,走内部 API Key 验证
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.api.routes.auth import _verify_internal_api_key
|
||||||
|
from app.config import settings
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from packages.adapters.redis.feature_flag_store import (
|
||||||
|
FeatureFlagConfig,
|
||||||
|
RedisFeatureFlagStore,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/internal/feature-flags", tags=["Internal"])
|
||||||
|
|
||||||
|
# 允许管理的 flag 白名单(防止误操作其他系统 flag)
|
||||||
|
ALLOWED_FLAGS = {
|
||||||
|
"render_engine",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_feature_flag_store() -> RedisFeatureFlagStore:
|
||||||
|
"""获取 Feature Flag 存储实例。"""
|
||||||
|
return RedisFeatureFlagStore(redis_url=settings.REDIS_URL)
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlagUpdateRequest(BaseModel):
|
||||||
|
"""Feature Flag 更新请求体。"""
|
||||||
|
|
||||||
|
enabled: bool = Field(..., description="是否启用")
|
||||||
|
percentage: int = Field(0, ge=0, le=100, description="灰度百分比 (0-100)")
|
||||||
|
whitelist: list[str] = Field(default_factory=list, description="白名单列表(如 user_id)")
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlagResponse(BaseModel):
|
||||||
|
"""Feature Flag 响应。"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
enabled: bool
|
||||||
|
percentage: int
|
||||||
|
whitelist: list[str]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, config: FeatureFlagConfig) -> "FeatureFlagResponse":
|
||||||
|
return cls(
|
||||||
|
name=config.name,
|
||||||
|
enabled=config.enabled,
|
||||||
|
percentage=config.percentage,
|
||||||
|
whitelist=sorted(config.whitelist),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlagCheckResponse(BaseModel):
|
||||||
|
"""Flag 激活检查响应。"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
active: bool
|
||||||
|
identifier: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_flag_name(name: str) -> None:
|
||||||
|
"""校验 flag 名称是否在允许列表中。"""
|
||||||
|
if name not in ALLOWED_FLAGS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Unsupported flag: {name}. Allowed: {sorted(ALLOWED_FLAGS)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[FeatureFlagResponse])
|
||||||
|
async def list_feature_flags(
|
||||||
|
_: bool = Depends(_verify_internal_api_key),
|
||||||
|
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||||
|
) -> list[FeatureFlagResponse]:
|
||||||
|
"""列出所有 Feature Flag。"""
|
||||||
|
try:
|
||||||
|
flags = store.list_all()
|
||||||
|
# 同时返回预定义的 flag(即使未设置也显示默认值)
|
||||||
|
result = []
|
||||||
|
for name in sorted(ALLOWED_FLAGS):
|
||||||
|
config = flags.get(name) or FeatureFlagConfig(name=name, enabled=False)
|
||||||
|
result.append(FeatureFlagResponse.from_config(config))
|
||||||
|
# 加上已存在但不在白名单中的 flag(只读展示)
|
||||||
|
for name, config in flags.items():
|
||||||
|
if name not in ALLOWED_FLAGS:
|
||||||
|
result.append(FeatureFlagResponse.from_config(config))
|
||||||
|
return sorted(result, key=lambda x: x.name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to list feature flags: %s", exc)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{name}", response_model=FeatureFlagResponse)
|
||||||
|
async def get_feature_flag(
|
||||||
|
name: str,
|
||||||
|
_: bool = Depends(_verify_internal_api_key),
|
||||||
|
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||||
|
) -> FeatureFlagResponse:
|
||||||
|
"""获取单个 Feature Flag 配置。"""
|
||||||
|
try:
|
||||||
|
config = store.get(name)
|
||||||
|
return FeatureFlagResponse.from_config(config)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to get feature flag %s: %s", name, exc)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{name}/check", response_model=FeatureFlagCheckResponse)
|
||||||
|
async def check_feature_flag(
|
||||||
|
name: str,
|
||||||
|
identifier: Optional[str] = Query(None, description="标识符,如 user_id"),
|
||||||
|
_: bool = Depends(_verify_internal_api_key),
|
||||||
|
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||||
|
) -> FeatureFlagCheckResponse:
|
||||||
|
"""检查某个标识符是否命中 Feature Flag。"""
|
||||||
|
try:
|
||||||
|
active = store.is_active(name, identifier=identifier)
|
||||||
|
return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to check feature flag %s: %s", name, exc)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{name}", response_model=FeatureFlagResponse)
|
||||||
|
async def update_feature_flag(
|
||||||
|
name: str,
|
||||||
|
request: FeatureFlagUpdateRequest,
|
||||||
|
_: bool = Depends(_verify_internal_api_key),
|
||||||
|
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||||
|
) -> FeatureFlagResponse:
|
||||||
|
"""更新 Feature Flag 配置。
|
||||||
|
|
||||||
|
只允许修改 ALLOWED_FLAGS 列表中的 flag。
|
||||||
|
"""
|
||||||
|
_validate_flag_name(name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = FeatureFlagConfig(
|
||||||
|
name=name,
|
||||||
|
enabled=request.enabled,
|
||||||
|
percentage=request.percentage,
|
||||||
|
whitelist=set(request.whitelist),
|
||||||
|
)
|
||||||
|
store.set(config)
|
||||||
|
logger.info(
|
||||||
|
"Feature flag updated: name=%s enabled=%s percentage=%d whitelist=%d",
|
||||||
|
name,
|
||||||
|
config.enabled,
|
||||||
|
config.percentage,
|
||||||
|
len(config.whitelist),
|
||||||
|
)
|
||||||
|
return FeatureFlagResponse.from_config(config)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to update feature flag %s: %s", name, exc)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||||
|
async def delete_feature_flag(
|
||||||
|
name: str,
|
||||||
|
_: bool = Depends(_verify_internal_api_key),
|
||||||
|
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||||
|
) :
|
||||||
|
"""删除 Feature Flag。
|
||||||
|
|
||||||
|
只允许删除 ALLOWED_FLAGS 列表中的 flag。
|
||||||
|
"""
|
||||||
|
_validate_flag_name(name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
deleted = store.delete(name)
|
||||||
|
logger.info("Feature flag deleted: name=%s deleted=%s", name, deleted)
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to delete feature flag %s: %s", name, exc)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}")
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
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
|
|
||||||
from app.schemas.generated_video import (
|
|
||||||
GeneratedVideoDownloadUrlResponse,
|
|
||||||
GeneratedVideoResponse,
|
|
||||||
ListGeneratedVideosResponse,
|
|
||||||
UpdateGeneratedVideoReviewRequest,
|
|
||||||
)
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
|
|
||||||
from packages.application import (
|
|
||||||
GetGeneratedVideoDownloadUrlUseCase,
|
|
||||||
GetGeneratedVideoUseCase,
|
|
||||||
ListGeneratedVideosUseCase,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _to_generated_video_response(item, download_url: str | None = None) -> 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,
|
|
||||||
status=item.status,
|
|
||||||
review_status=item.review_status,
|
|
||||||
generation_params=item.generation_params,
|
|
||||||
download_url=download_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ListGeneratedVideosResponse)
|
|
||||||
def list_generated_videos(
|
|
||||||
project_id: str | None = Query(None),
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
|
||||||
project_repository: Any = Depends(get_project_repository),
|
|
||||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
|
||||||
) -> ListGeneratedVideosResponse:
|
|
||||||
user_id = authenticated_user.user.id
|
|
||||||
use_case = ListGeneratedVideosUseCase(generated_video_repository)
|
|
||||||
|
|
||||||
if project_id:
|
|
||||||
# If project_id provided, check access and filter by project
|
|
||||||
project = project_repository.find_by_id(project_id)
|
|
||||||
if project is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
|
||||||
items = use_case.execute(project_id)
|
|
||||||
else:
|
|
||||||
# If no project_id, list all videos from accessible projects
|
|
||||||
accessible_projects = project_repository.find_accessible_projects(user_id)
|
|
||||||
all_items = []
|
|
||||||
for proj in accessible_projects:
|
|
||||||
all_items.extend(use_case.execute(proj.id))
|
|
||||||
items = all_items
|
|
||||||
|
|
||||||
# Generate download URLs for each video
|
|
||||||
responses = []
|
|
||||||
for item in items:
|
|
||||||
download_url = storage_service.get_download_url(item.file_url)
|
|
||||||
responses.append(_to_generated_video_response(item, download_url=download_url))
|
|
||||||
return ListGeneratedVideosResponse(items=responses)
|
|
||||||
|
|
||||||
|
|
||||||
@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),
|
|
||||||
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")
|
|
||||||
download_url = storage_service.get_download_url(item.file_url)
|
|
||||||
return _to_generated_video_response(item, download_url=download_url)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{video_id}/review", response_model=GeneratedVideoResponse)
|
|
||||||
def update_generated_video_review_status(
|
|
||||||
video_id: str,
|
|
||||||
request: UpdateGeneratedVideoReviewRequest,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
|
||||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
|
||||||
) -> GeneratedVideoResponse:
|
|
||||||
video = generated_video_repository.get(video_id)
|
|
||||||
if video is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
|
||||||
video.review_status = request.review_status
|
|
||||||
updated = generated_video_repository.update(video)
|
|
||||||
download_url = storage_service.get_download_url(updated.file_url)
|
|
||||||
return _to_generated_video_response(updated, download_url=download_url)
|
|
||||||
|
|
||||||
|
|
||||||
@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),
|
|
||||||
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")
|
|
||||||
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)
|
|
||||||
Executable → Regular
+5
-12
@@ -3,6 +3,7 @@ import random
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from app.api.routes._helpers import check_project_access
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.core.storage import OSSStorageService, get_storage_service
|
from app.core.storage import OSSStorageService, get_storage_service
|
||||||
from app.core.task_enqueue import (
|
from app.core.task_enqueue import (
|
||||||
@@ -10,7 +11,6 @@ from app.core.task_enqueue import (
|
|||||||
USER_PENDING_LIMIT,
|
USER_PENDING_LIMIT,
|
||||||
GlobalQueueFull,
|
GlobalQueueFull,
|
||||||
UserPendingLimitExceeded,
|
UserPendingLimitExceeded,
|
||||||
check_queue_limits,
|
|
||||||
safe_enqueue_generation_task,
|
safe_enqueue_generation_task,
|
||||||
)
|
)
|
||||||
from app.dependencies import (
|
from app.dependencies import (
|
||||||
@@ -43,15 +43,6 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
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:
|
def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||||
return GenerationTaskResponse(
|
return GenerationTaskResponse(
|
||||||
id=task.id,
|
id=task.id,
|
||||||
@@ -276,6 +267,8 @@ def create_generation_task(
|
|||||||
source_edit_plan_id=request.source_edit_plan_id,
|
source_edit_plan_id=request.source_edit_plan_id,
|
||||||
asset_select_mode=request.asset_select_mode,
|
asset_select_mode=request.asset_select_mode,
|
||||||
batch_id=batch_id,
|
batch_id=batch_id,
|
||||||
|
auto_retry_enabled=request.auto_retry_enabled,
|
||||||
|
auto_retry_max=request.auto_retry_max,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -339,7 +332,7 @@ def get_generation_task(
|
|||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
|
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
|
||||||
if task.project_id:
|
if task.project_id:
|
||||||
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
||||||
return _to_generation_task_response(task)
|
return _to_generation_task_response(task)
|
||||||
|
|
||||||
|
|
||||||
@@ -356,7 +349,7 @@ def list_generation_results(
|
|||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
|
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
|
||||||
if task.project_id:
|
if task.project_id:
|
||||||
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
||||||
use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
|
use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
|
||||||
items = use_case.execute(task_id)
|
items = use_case.execute(task_id)
|
||||||
responses = []
|
responses = []
|
||||||
|
|||||||
Executable
+120
@@ -0,0 +1,120 @@
|
|||||||
|
"""渲染结果内部下载接口。
|
||||||
|
|
||||||
|
通过内部 API Key 鉴权,为灰度对比工具等内部系统提供渲染结果下载能力。
|
||||||
|
|
||||||
|
API:
|
||||||
|
GET /api/v1/internal/render/videos/{video_id}/download-url - 获取单个视频下载URL
|
||||||
|
GET /api/v1/internal/render/tasks/{task_id}/videos - 获取任务下所有视频及下载URL
|
||||||
|
|
||||||
|
鉴权:X-API-Key header,走内部 API Key 验证
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.api.routes.auth import _verify_internal_api_key
|
||||||
|
from app.core.storage import OSSStorageService, get_storage_service
|
||||||
|
from app.dependencies import get_generated_video_repository
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/internal/render", tags=["Internal"])
|
||||||
|
|
||||||
|
|
||||||
|
class InternalRenderVideoItem(BaseModel):
|
||||||
|
"""内部渲染视频项。"""
|
||||||
|
|
||||||
|
video_id: str
|
||||||
|
generation_task_id: str
|
||||||
|
project_id: str
|
||||||
|
name: str
|
||||||
|
file_url: str
|
||||||
|
file_size: int | None = None
|
||||||
|
duration: float | None = None
|
||||||
|
width: int | None = None
|
||||||
|
height: int | None = None
|
||||||
|
fps: float | None = None
|
||||||
|
status: str
|
||||||
|
download_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class InternalRenderTaskVideosResponse(BaseModel):
|
||||||
|
"""任务下所有渲染视频响应。"""
|
||||||
|
|
||||||
|
task_id: str
|
||||||
|
count: int
|
||||||
|
videos: list[InternalRenderVideoItem]
|
||||||
|
|
||||||
|
|
||||||
|
class InternalRenderDownloadUrlResponse(BaseModel):
|
||||||
|
"""单个视频下载URL响应。"""
|
||||||
|
|
||||||
|
video_id: str
|
||||||
|
download_url: str
|
||||||
|
|
||||||
|
|
||||||
|
def _video_to_item(video: Any, download_url: str) -> InternalRenderVideoItem:
|
||||||
|
"""将 GeneratedVideo 领域对象转为响应项。"""
|
||||||
|
return InternalRenderVideoItem(
|
||||||
|
video_id=video.id,
|
||||||
|
generation_task_id=video.generation_task_id,
|
||||||
|
project_id=video.project_id,
|
||||||
|
name=video.name,
|
||||||
|
file_url=video.file_url,
|
||||||
|
file_size=getattr(video, "file_size", None),
|
||||||
|
duration=getattr(video, "duration", None),
|
||||||
|
width=getattr(video, "width", None),
|
||||||
|
height=getattr(video, "height", None),
|
||||||
|
fps=getattr(video, "fps", None),
|
||||||
|
status=video.status,
|
||||||
|
download_url=download_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/videos/{video_id}/download-url", response_model=InternalRenderDownloadUrlResponse)
|
||||||
|
def get_render_video_download_url(
|
||||||
|
video_id: str,
|
||||||
|
_: bool = Depends(_verify_internal_api_key),
|
||||||
|
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||||
|
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||||
|
) -> InternalRenderDownloadUrlResponse:
|
||||||
|
"""获取单个渲染视频的下载URL(预签名)。"""
|
||||||
|
video = generated_video_repository.get(video_id)
|
||||||
|
if video is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
||||||
|
|
||||||
|
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
|
||||||
|
logger.info("内部渲染下载URL生成: video_id=%s", video_id)
|
||||||
|
return InternalRenderDownloadUrlResponse(video_id=video_id, download_url=download_url)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}/videos", response_model=InternalRenderTaskVideosResponse)
|
||||||
|
def get_render_task_videos(
|
||||||
|
task_id: str,
|
||||||
|
status: str | None = Query(None, description="按状态筛选,如 completed/failed"),
|
||||||
|
_: bool = Depends(_verify_internal_api_key),
|
||||||
|
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||||
|
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||||
|
) -> InternalRenderTaskVideosResponse:
|
||||||
|
"""获取生成任务下所有渲染视频及下载URL。"""
|
||||||
|
videos = generated_video_repository.list_by_generation_task(task_id)
|
||||||
|
|
||||||
|
# 状态筛选
|
||||||
|
if status:
|
||||||
|
videos = [v for v in videos if v.status == status]
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for video in videos:
|
||||||
|
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
|
||||||
|
items.append(_video_to_item(video, download_url))
|
||||||
|
|
||||||
|
logger.info("内部渲染任务视频查询: task_id=%s count=%d", task_id, len(items))
|
||||||
|
return InternalRenderTaskVideosResponse(
|
||||||
|
task_id=task_id,
|
||||||
|
count=len(items),
|
||||||
|
videos=items,
|
||||||
|
)
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
"""Job API 路由 — Phase 8 任务 2.10.
|
|
||||||
|
|
||||||
提供统一异步任务管理 RESTful 接口:
|
|
||||||
- POST /api/v1/jobs 创建任务
|
|
||||||
- GET /api/v1/jobs/{job_id} 任务详情
|
|
||||||
- GET /api/v1/projects/{project_id}/jobs 项目任务列表
|
|
||||||
- GET /api/v1/projects/{project_id}/jobs/stats 任务统计
|
|
||||||
- PUT /api/v1/jobs/{job_id}/progress 更新进度
|
|
||||||
- POST /api/v1/jobs/{job_id}/complete 标记完成
|
|
||||||
- POST /api/v1/jobs/{job_id}/fail 标记失败
|
|
||||||
- POST /api/v1/jobs/{job_id}/retry 重试任务
|
|
||||||
- POST /api/v1/jobs/{job_id}/cancel 取消任务
|
|
||||||
- POST /api/v1/jobs/{job_id}/submit 提交执行
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
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_db_session, get_job_repository, get_project_repository
|
|
||||||
from app.schemas.job import (
|
|
||||||
CompleteJobRequest,
|
|
||||||
CreateJobRequest,
|
|
||||||
FailJobRequest,
|
|
||||||
JobResponse,
|
|
||||||
JobStatisticsResponse,
|
|
||||||
ListJobsResponse,
|
|
||||||
UpdateProgressRequest,
|
|
||||||
job_to_response,
|
|
||||||
)
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
||||||
|
|
||||||
from packages.application.jobs import (
|
|
||||||
CancelJobUseCase,
|
|
||||||
CompleteJobCommand,
|
|
||||||
CompleteJobUseCase,
|
|
||||||
CreateJobCommand,
|
|
||||||
CreateJobUseCase,
|
|
||||||
FailJobCommand,
|
|
||||||
FailJobUseCase,
|
|
||||||
GetJobStatisticsUseCase,
|
|
||||||
GetJobUseCase,
|
|
||||||
ListJobsUseCase,
|
|
||||||
RetryJobUseCase,
|
|
||||||
SubmitJobUseCase,
|
|
||||||
UpdateJobProgressCommand,
|
|
||||||
UpdateJobProgressUseCase,
|
|
||||||
)
|
|
||||||
from packages.domain.job import JobType
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
# 任务类型 → Celery task name 映射
|
|
||||||
_JOB_TYPE_TO_CELERY_TASK: dict[str, str] = {
|
|
||||||
JobType.VIDEO_COMPOSE: "worker.compose_video",
|
|
||||||
JobType.RENDER_EDIT_PLAN: "worker.render_edit_plan",
|
|
||||||
JobType.ASSET_INGEST: "worker.ingest_asset",
|
|
||||||
JobType.CLASSIFICATION: "worker.classify_asset",
|
|
||||||
JobType.VOICE_EXTRACTION: "worker.extract_voice",
|
|
||||||
JobType.GENERATION: "worker.generate_video",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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.post("/jobs", response_model=JobResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
def create_job(
|
|
||||||
request: CreateJobRequest,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
job_repo: Any = Depends(get_job_repository),
|
|
||||||
project_repository: Any = Depends(get_project_repository),
|
|
||||||
) -> JobResponse:
|
|
||||||
"""创建异步任务。
|
|
||||||
|
|
||||||
创建后任务处于 pending 状态,需要调用 /submit 提交执行。
|
|
||||||
"""
|
|
||||||
_check_project_access(request.project_id, authenticated_user.user.id, project_repository)
|
|
||||||
|
|
||||||
# 校验 job_type
|
|
||||||
try:
|
|
||||||
JobType(request.job_type)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"不支持的任务类型: {request.job_type}," f"可选值: {[t.value for t in JobType]}",
|
|
||||||
)
|
|
||||||
|
|
||||||
use_case = CreateJobUseCase(job_repo)
|
|
||||||
job = use_case.execute(
|
|
||||||
CreateJobCommand(
|
|
||||||
project_id=request.project_id,
|
|
||||||
job_type=request.job_type,
|
|
||||||
payload=request.payload,
|
|
||||||
source_id=request.source_id,
|
|
||||||
created_by_user_id=authenticated_user.user.id,
|
|
||||||
max_retries=request.max_retries,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return job_to_response(job)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 提交执行 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/jobs/{job_id}/submit", response_model=JobResponse)
|
|
||||||
def submit_job(
|
|
||||||
job_id: str,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
job_repo: Any = Depends(get_job_repository),
|
|
||||||
) -> JobResponse:
|
|
||||||
"""提交任务执行。
|
|
||||||
|
|
||||||
将任务状态从 pending 切换为 running,并 dispatch Celery 异步任务。
|
|
||||||
"""
|
|
||||||
# 权限检查:先获取任务并验证权限,再执行状态变更
|
|
||||||
job = job_repo.get(job_id)
|
|
||||||
if job is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
||||||
if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied to this job")
|
|
||||||
|
|
||||||
use_case = SubmitJobUseCase(job_repo)
|
|
||||||
|
|
||||||
try:
|
|
||||||
job = use_case.execute(job_id)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
# Dispatch Celery 任务
|
|
||||||
celery_task_name = _JOB_TYPE_TO_CELERY_TASK.get(job.job_type.value)
|
|
||||||
if celery_task_name:
|
|
||||||
result = celery_app.send_task(celery_task_name, args=[job.id], kwargs=job.payload)
|
|
||||||
job.celery_task_id = result.id
|
|
||||||
job_repo.update(job)
|
|
||||||
logger.info("已提交 Celery 任务: job_id=%s celery_task_id=%s", job.id, result.id)
|
|
||||||
|
|
||||||
return job_to_response(job)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 查询接口 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/jobs/{job_id}", response_model=JobResponse)
|
|
||||||
def get_job(
|
|
||||||
job_id: str,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
job_repo: Any = Depends(get_job_repository),
|
|
||||||
) -> JobResponse:
|
|
||||||
"""获取任务详情。"""
|
|
||||||
use_case = GetJobUseCase(job_repo)
|
|
||||||
job = use_case.execute(job_id)
|
|
||||||
if job is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
||||||
return job_to_response(job)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/projects/{project_id}/jobs", response_model=ListJobsResponse)
|
|
||||||
def list_project_jobs(
|
|
||||||
project_id: str,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
job_repo: Any = Depends(get_job_repository),
|
|
||||||
project_repository: Any = Depends(get_project_repository),
|
|
||||||
job_type: str | None = Query(default=None, description="按任务类型过滤"),
|
|
||||||
status_filter: str | None = Query(default=None, alias="status", description="按状态过滤"),
|
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
|
||||||
offset: int = Query(default=0, ge=0),
|
|
||||||
) -> ListJobsResponse:
|
|
||||||
"""获取项目下的任务列表。"""
|
|
||||||
_check_project_access(project_id, authenticated_user.user.id, project_repository)
|
|
||||||
|
|
||||||
use_case = ListJobsUseCase(job_repo)
|
|
||||||
jobs = use_case.execute(
|
|
||||||
project_id=project_id,
|
|
||||||
job_type=job_type,
|
|
||||||
status=status_filter,
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
)
|
|
||||||
items = [job_to_response(j) for j in jobs]
|
|
||||||
return ListJobsResponse(items=items, total=len(items))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/projects/{project_id}/jobs/stats", response_model=JobStatisticsResponse)
|
|
||||||
def get_job_statistics(
|
|
||||||
project_id: str,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
job_repo: Any = Depends(get_job_repository),
|
|
||||||
project_repository: Any = Depends(get_project_repository),
|
|
||||||
) -> JobStatisticsResponse:
|
|
||||||
"""获取项目任务统计摘要。"""
|
|
||||||
_check_project_access(project_id, authenticated_user.user.id, project_repository)
|
|
||||||
|
|
||||||
use_case = GetJobStatisticsUseCase(job_repo)
|
|
||||||
stats = use_case.execute(project_id)
|
|
||||||
return JobStatisticsResponse(**stats)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 进度更新 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/jobs/{job_id}/progress", response_model=JobResponse)
|
|
||||||
def update_job_progress(
|
|
||||||
job_id: str,
|
|
||||||
request: UpdateProgressRequest,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
job_repo: Any = Depends(get_job_repository),
|
|
||||||
) -> JobResponse:
|
|
||||||
"""更新任务进度。"""
|
|
||||||
use_case = UpdateJobProgressUseCase(job_repo)
|
|
||||||
|
|
||||||
try:
|
|
||||||
job = use_case.execute(
|
|
||||||
UpdateJobProgressCommand(
|
|
||||||
job_id=job_id,
|
|
||||||
progress=request.progress,
|
|
||||||
current_stage=request.current_stage,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
return job_to_response(job)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 完成 / 失败 ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/jobs/{job_id}/complete", response_model=JobResponse)
|
|
||||||
def complete_job(
|
|
||||||
job_id: str,
|
|
||||||
request: CompleteJobRequest,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
job_repo: Any = Depends(get_job_repository),
|
|
||||||
) -> JobResponse:
|
|
||||||
"""标记任务完成。"""
|
|
||||||
use_case = CompleteJobUseCase(job_repo)
|
|
||||||
|
|
||||||
try:
|
|
||||||
job = use_case.execute(CompleteJobCommand(job_id=job_id, result=request.result))
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
return job_to_response(job)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/jobs/{job_id}/fail", response_model=JobResponse)
|
|
||||||
def fail_job(
|
|
||||||
job_id: str,
|
|
||||||
request: FailJobRequest,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
job_repo: Any = Depends(get_job_repository),
|
|
||||||
) -> JobResponse:
|
|
||||||
"""标记任务失败。"""
|
|
||||||
use_case = FailJobUseCase(job_repo)
|
|
||||||
|
|
||||||
try:
|
|
||||||
job = use_case.execute(FailJobCommand(job_id=job_id, error_message=request.error_message))
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
return job_to_response(job)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 重试 / 取消 ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/jobs/{job_id}/retry", response_model=JobResponse)
|
|
||||||
def retry_job(
|
|
||||||
job_id: str,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
job_repo: Any = Depends(get_job_repository),
|
|
||||||
) -> JobResponse:
|
|
||||||
"""重试失败任务。
|
|
||||||
|
|
||||||
将任务重置为 pending,retry_count + 1,但不自动 dispatch。
|
|
||||||
需要再次调用 /submit 提交执行。
|
|
||||||
"""
|
|
||||||
# 权限检查:先获取任务并验证权限,再执行状态变更
|
|
||||||
job = job_repo.get(job_id)
|
|
||||||
if job is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
||||||
if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied to this job")
|
|
||||||
|
|
||||||
use_case = RetryJobUseCase(job_repo)
|
|
||||||
|
|
||||||
try:
|
|
||||||
job = use_case.execute(job_id)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
return job_to_response(job)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/jobs/{job_id}/cancel", response_model=JobResponse)
|
|
||||||
def cancel_job(
|
|
||||||
job_id: str,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
job_repo: Any = Depends(get_job_repository),
|
|
||||||
) -> JobResponse:
|
|
||||||
"""取消任务。"""
|
|
||||||
# 权限检查:先获取任务并验证权限,再执行状态变更
|
|
||||||
job = job_repo.get(job_id)
|
|
||||||
if job is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
||||||
if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied to this job")
|
|
||||||
|
|
||||||
use_case = CancelJobUseCase(job_repo)
|
|
||||||
|
|
||||||
try:
|
|
||||||
job = use_case.execute(job_id)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
return job_to_response(job)
|
|
||||||
Executable → Regular
+4
-4
@@ -7,7 +7,7 @@ from app.schemas.project import (
|
|||||||
ListProjectsResponse,
|
ListProjectsResponse,
|
||||||
ProjectResponse,
|
ProjectResponse,
|
||||||
)
|
)
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
|
|
||||||
from packages.application import (
|
from packages.application import (
|
||||||
CreateProjectCommand,
|
CreateProjectCommand,
|
||||||
@@ -72,12 +72,12 @@ def create_project(
|
|||||||
return _to_project_response(project)
|
return _to_project_response(project)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{project_id}")
|
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||||
def delete_project(
|
def delete_project(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
project_repository: Any = Depends(get_project_repository),
|
project_repository: Any = Depends(get_project_repository),
|
||||||
):
|
) -> dict:
|
||||||
use_case = DeleteProjectUseCase(project_repository)
|
use_case = DeleteProjectUseCase(project_repository)
|
||||||
try:
|
try:
|
||||||
deleted = use_case.execute(project_id, authenticated_user.user.id)
|
deleted = use_case.execute(project_id, authenticated_user.user.id)
|
||||||
@@ -88,4 +88,4 @@ def delete_project(
|
|||||||
)
|
)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||||
return {"message": "Project deleted successfully"}
|
return
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
"""Recipe CRUD + use routes."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
from app.dependencies import get_db_session, get_user_repository
|
|
||||||
from app.schemas.recipe import (
|
|
||||||
CreateRecipeRequest,
|
|
||||||
ListRecipesResponse,
|
|
||||||
RecipeItemResponse,
|
|
||||||
RecipeResponse,
|
|
||||||
UpdateRecipeRequest,
|
|
||||||
UseRecipeResponse,
|
|
||||||
)
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from packages.adapters.sqlalchemy_impl.recipe_repository import SQLAlchemyRecipeRepository
|
|
||||||
from packages.application.recipe.commands import (
|
|
||||||
CreateRecipeCommand,
|
|
||||||
RecipeItemCommand,
|
|
||||||
UpdateRecipeCommand,
|
|
||||||
)
|
|
||||||
from packages.application.recipe.use_cases import (
|
|
||||||
CreateRecipeUseCase,
|
|
||||||
DeleteRecipeUseCase,
|
|
||||||
FeatureDisabledError,
|
|
||||||
GetRecipeUseCase,
|
|
||||||
ListRecipesUseCase,
|
|
||||||
NotFoundError,
|
|
||||||
UpdateRecipeUseCase,
|
|
||||||
UseRecipeUseCase,
|
|
||||||
)
|
|
||||||
from packages.ports.user_repository import UserRepository
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _get_recipe_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyRecipeRepository:
|
|
||||||
return SQLAlchemyRecipeRepository(session)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
|
|
||||||
user = user_repository.find_by_id(user_id)
|
|
||||||
if user is None:
|
|
||||||
return "free"
|
|
||||||
return getattr(user, "subscription_plan", "free") or "free"
|
|
||||||
|
|
||||||
|
|
||||||
def _item_to_response(item) -> RecipeItemResponse:
|
|
||||||
return RecipeItemResponse(
|
|
||||||
id=item.id,
|
|
||||||
recipe_id=item.recipe_id,
|
|
||||||
item_type=item.item_type,
|
|
||||||
item_id=item.item_id,
|
|
||||||
position=item.position,
|
|
||||||
metadata=item.metadata_,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_response(recipe) -> RecipeResponse:
|
|
||||||
return RecipeResponse(
|
|
||||||
id=recipe.id,
|
|
||||||
user_id=recipe.user_id,
|
|
||||||
name=recipe.name,
|
|
||||||
description=recipe.description,
|
|
||||||
template_id=recipe.template_id,
|
|
||||||
generation_params=recipe.generation_params,
|
|
||||||
items=[_item_to_response(i) for i in getattr(recipe, "items", [])],
|
|
||||||
is_active=recipe.is_active,
|
|
||||||
metadata=recipe.metadata_,
|
|
||||||
created_at=recipe.created_at,
|
|
||||||
updated_at=recipe.updated_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ListRecipesResponse)
|
|
||||||
def list_recipes(
|
|
||||||
skip: int = Query(0, ge=0),
|
|
||||||
limit: int = Query(50, ge=1, le=200),
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
|
|
||||||
) -> ListRecipesResponse:
|
|
||||||
user_id = authenticated_user.user.id
|
|
||||||
use_case = ListRecipesUseCase(recipe_repository)
|
|
||||||
recipes = use_case.execute(user_id, skip=skip, limit=limit)
|
|
||||||
total = recipe_repository.count_by_user(user_id)
|
|
||||||
return ListRecipesResponse(
|
|
||||||
items=[_to_response(r) for r in recipes],
|
|
||||||
total=total,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{recipe_id}", response_model=RecipeResponse)
|
|
||||||
def get_recipe(
|
|
||||||
recipe_id: str,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
|
|
||||||
) -> RecipeResponse:
|
|
||||||
user_id = authenticated_user.user.id
|
|
||||||
use_case = GetRecipeUseCase(recipe_repository)
|
|
||||||
recipe = use_case.execute(recipe_id, user_id)
|
|
||||||
if recipe is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
|
|
||||||
return _to_response(recipe)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=RecipeResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
def create_recipe(
|
|
||||||
request: CreateRecipeRequest,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
|
|
||||||
) -> RecipeResponse:
|
|
||||||
user_id = authenticated_user.user.id
|
|
||||||
command = CreateRecipeCommand(
|
|
||||||
user_id=user_id,
|
|
||||||
name=request.name,
|
|
||||||
description=request.description,
|
|
||||||
template_id=request.template_id,
|
|
||||||
generation_params=request.generation_params,
|
|
||||||
items=[
|
|
||||||
RecipeItemCommand(
|
|
||||||
item_type=ic.item_type,
|
|
||||||
item_id=ic.item_id,
|
|
||||||
position=ic.position,
|
|
||||||
metadata_=ic.metadata_,
|
|
||||||
)
|
|
||||||
for ic in request.items
|
|
||||||
],
|
|
||||||
metadata_=request.metadata_,
|
|
||||||
)
|
|
||||||
use_case = CreateRecipeUseCase(recipe_repository)
|
|
||||||
recipe = use_case.execute(command)
|
|
||||||
return _to_response(recipe)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{recipe_id}", response_model=RecipeResponse)
|
|
||||||
def update_recipe(
|
|
||||||
recipe_id: str,
|
|
||||||
request: UpdateRecipeRequest,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
|
|
||||||
) -> RecipeResponse:
|
|
||||||
user_id = authenticated_user.user.id
|
|
||||||
command = UpdateRecipeCommand(
|
|
||||||
recipe_id=recipe_id,
|
|
||||||
user_id=user_id,
|
|
||||||
name=request.name,
|
|
||||||
description=request.description,
|
|
||||||
template_id=request.template_id,
|
|
||||||
generation_params=request.generation_params,
|
|
||||||
items=(
|
|
||||||
[
|
|
||||||
RecipeItemCommand(
|
|
||||||
item_type=ic.item_type,
|
|
||||||
item_id=ic.item_id,
|
|
||||||
position=ic.position,
|
|
||||||
metadata_=ic.metadata_,
|
|
||||||
)
|
|
||||||
for ic in request.items
|
|
||||||
]
|
|
||||||
if request.items is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
metadata_=request.metadata_,
|
|
||||||
)
|
|
||||||
use_case = UpdateRecipeUseCase(recipe_repository)
|
|
||||||
try:
|
|
||||||
recipe = use_case.execute(command)
|
|
||||||
except NotFoundError:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
|
|
||||||
return _to_response(recipe)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{recipe_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
|
||||||
def delete_recipe(
|
|
||||||
recipe_id: str,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
|
|
||||||
) -> Response:
|
|
||||||
user_id = authenticated_user.user.id
|
|
||||||
use_case = DeleteRecipeUseCase(recipe_repository)
|
|
||||||
deleted = use_case.execute(recipe_id, user_id)
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
|
|
||||||
return Response(status_code=204)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{recipe_id}/use", response_model=UseRecipeResponse)
|
|
||||||
def use_recipe(
|
|
||||||
recipe_id: str,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
|
|
||||||
user_repository: UserRepository = Depends(get_user_repository),
|
|
||||||
) -> UseRecipeResponse:
|
|
||||||
user_id = authenticated_user.user.id
|
|
||||||
plan_name = _get_user_plan(user_id, user_repository)
|
|
||||||
use_case = UseRecipeUseCase(recipe_repository)
|
|
||||||
try:
|
|
||||||
result = use_case.execute(recipe_id, user_id, user_plan=plan_name)
|
|
||||||
except FeatureDisabledError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=str(exc),
|
|
||||||
)
|
|
||||||
except NotFoundError:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
|
|
||||||
|
|
||||||
return UseRecipeResponse(
|
|
||||||
recipe=_to_response(result.recipe),
|
|
||||||
warnings=[{"item_type": w.item_type, "item_id": w.item_id, "position": w.position} for w in result.warnings],
|
|
||||||
)
|
|
||||||
@@ -92,7 +92,7 @@ def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo:
|
|||||||
@router.get("/current", response_model=SubscriptionInfo)
|
@router.get("/current", response_model=SubscriptionInfo)
|
||||||
async def get_current_subscription(
|
async def get_current_subscription(
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
):
|
) -> SubscriptionInfo:
|
||||||
"""获取当前订阅信息"""
|
"""获取当前订阅信息"""
|
||||||
return _build_subscription_info(current_user)
|
return _build_subscription_info(current_user)
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ async def get_current_subscription(
|
|||||||
@router.get("/billing-records", response_model=List[BillingRecord])
|
@router.get("/billing-records", response_model=List[BillingRecord])
|
||||||
async def get_billing_records(
|
async def get_billing_records(
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
):
|
) -> List[BillingRecord]:
|
||||||
"""获取账单记录列表"""
|
"""获取账单记录列表"""
|
||||||
from packages.adapters.sqlalchemy_impl.billing_repository import SQLAlchemyBillingRepository
|
from packages.adapters.sqlalchemy_impl.billing_repository import SQLAlchemyBillingRepository
|
||||||
from packages.adapters.sqlalchemy_impl.session import SessionLocal
|
from packages.adapters.sqlalchemy_impl.session import SessionLocal
|
||||||
@@ -134,7 +134,7 @@ async def change_plan(
|
|||||||
request: ChangePlanRequest,
|
request: ChangePlanRequest,
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
user_repository: UserRepository = Depends(get_user_repository),
|
user_repository: UserRepository = Depends(get_user_repository),
|
||||||
):
|
) -> ChangePlanResponse:
|
||||||
"""变更订阅套餐(升级/降级)"""
|
"""变更订阅套餐(升级/降级)"""
|
||||||
# TODO: 接入支付验证(支付宝/微信支付)
|
# TODO: 接入支付验证(支付宝/微信支付)
|
||||||
valid_plans = {"free", "standard", "pro", "enterprise"}
|
valid_plans = {"free", "standard", "pro", "enterprise"}
|
||||||
@@ -186,7 +186,7 @@ async def change_plan(
|
|||||||
async def cancel_subscription(
|
async def cancel_subscription(
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
user_repository: UserRepository = Depends(get_user_repository),
|
user_repository: UserRepository = Depends(get_user_repository),
|
||||||
):
|
) -> SimpleResponse:
|
||||||
"""取消订阅"""
|
"""取消订阅"""
|
||||||
user = current_user.user
|
user = current_user.user
|
||||||
if user.subscription_plan == "free":
|
if user.subscription_plan == "free":
|
||||||
@@ -212,7 +212,7 @@ async def payment_callback(
|
|||||||
amount: float,
|
amount: float,
|
||||||
payment_method: str = "alipay",
|
payment_method: str = "alipay",
|
||||||
payment_id: str = "",
|
payment_id: str = "",
|
||||||
):
|
) -> dict:
|
||||||
"""支付回调 - 在事务中更新账单和订阅状态
|
"""支付回调 - 在事务中更新账单和订阅状态
|
||||||
|
|
||||||
注意:生产环境需要验证支付签名
|
注意:生产环境需要验证支付签名
|
||||||
@@ -232,7 +232,7 @@ async def payment_callback(
|
|||||||
|
|
||||||
# 创建账单记录
|
# 创建账单记录
|
||||||
record_id = uuid.uuid4().hex
|
record_id = uuid.uuid4().hex
|
||||||
record = repo.create(
|
repo.create(
|
||||||
{
|
{
|
||||||
"id": record_id,
|
"id": record_id,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
@@ -263,7 +263,7 @@ async def payment_callback(
|
|||||||
async def toggle_auto_renew(
|
async def toggle_auto_renew(
|
||||||
request: ToggleAutoRenewRequest,
|
request: ToggleAutoRenewRequest,
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
):
|
) -> SimpleResponse:
|
||||||
"""切换自动续费"""
|
"""切换自动续费"""
|
||||||
# TODO: 实际需要在数据库中存储 auto_renew 字段
|
# TODO: 实际需要在数据库中存储 auto_renew 字段
|
||||||
status_text = "已开启自动续费" if request.enabled else "已关闭自动续费"
|
status_text = "已开启自动续费" if request.enabled else "已关闭自动续费"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from app.schemas.tag import (
|
|||||||
ListTagsResponse,
|
ListTagsResponse,
|
||||||
TagResponse,
|
TagResponse,
|
||||||
)
|
)
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
|
|
||||||
from packages.domain import Tag
|
from packages.domain import Tag
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ def create_tag(
|
|||||||
return TagResponse(id=created.id, name=created.name, created_at=created.created_at)
|
return TagResponse(id=created.id, name=created.name, created_at=created.created_at)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{tag_id}", status_code=204)
|
@router.delete("/{tag_id}", status_code=204, response_class=Response)
|
||||||
def delete_tag(
|
def delete_tag(
|
||||||
tag_id: str,
|
tag_id: str,
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ from app.schemas.task_center import (
|
|||||||
ProjectTaskResponse,
|
ProjectTaskResponse,
|
||||||
UserTaskResponse,
|
UserTaskResponse,
|
||||||
)
|
)
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from packages.application import (
|
from packages.application import (
|
||||||
CreateGenerationTaskCommand,
|
CreateGenerationTaskCommand,
|
||||||
CreateGenerationTaskUseCase,
|
CreateGenerationTaskUseCase,
|
||||||
|
RetryGenerationTaskUseCase,
|
||||||
SubmitIngestJobCommand,
|
SubmitIngestJobCommand,
|
||||||
SubmitIngestJobUseCase,
|
SubmitIngestJobUseCase,
|
||||||
)
|
)
|
||||||
@@ -34,6 +35,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
DEFAULT_PAGE_SIZE = 50
|
||||||
|
MAX_PAGE_SIZE = 200
|
||||||
|
|
||||||
|
|
||||||
def _humanize_task_error(error_message: str) -> str:
|
def _humanize_task_error(error_message: str) -> str:
|
||||||
raw = (error_message or "").strip()
|
raw = (error_message or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
@@ -63,6 +68,8 @@ def _generation_step(task) -> str:
|
|||||||
return "生成完成"
|
return "生成完成"
|
||||||
if s == "failed":
|
if s == "failed":
|
||||||
return "生成失败"
|
return "生成失败"
|
||||||
|
if s == "cancelled":
|
||||||
|
return "已取消"
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
@@ -79,6 +86,26 @@ def _ingest_step(job) -> str:
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _generation_task_to_user_response(task) -> UserTaskResponse:
|
||||||
|
return UserTaskResponse(
|
||||||
|
id=f"generation:{task.id}",
|
||||||
|
task_type="generation",
|
||||||
|
project_id=task.project_id,
|
||||||
|
template_id=task.template_id,
|
||||||
|
status=_status_value(task.status),
|
||||||
|
progress=task.progress,
|
||||||
|
current_step=_generation_step(task),
|
||||||
|
error_message=task.error_message,
|
||||||
|
error_info=task.error_info or {},
|
||||||
|
user_message=_humanize_task_error(task.error_message),
|
||||||
|
retryable=_status_value(task.status) == "failed",
|
||||||
|
retry_count=task.retry_count or 0,
|
||||||
|
source_id=task.id,
|
||||||
|
created_at=task.created_at,
|
||||||
|
updated_at=task.completed_at or task.started_at or task.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _generation_task_to_project_response(task) -> ProjectTaskResponse:
|
def _generation_task_to_project_response(task) -> ProjectTaskResponse:
|
||||||
return ProjectTaskResponse(
|
return ProjectTaskResponse(
|
||||||
id=f"generation:{task.id}",
|
id=f"generation:{task.id}",
|
||||||
@@ -88,8 +115,10 @@ def _generation_task_to_project_response(task) -> ProjectTaskResponse:
|
|||||||
progress=task.progress,
|
progress=task.progress,
|
||||||
current_step=_generation_step(task),
|
current_step=_generation_step(task),
|
||||||
error_message=task.error_message,
|
error_message=task.error_message,
|
||||||
|
error_info=task.error_info or {},
|
||||||
user_message=_humanize_task_error(task.error_message),
|
user_message=_humanize_task_error(task.error_message),
|
||||||
retryable=_status_value(task.status) == "failed",
|
retryable=_status_value(task.status) == "failed",
|
||||||
|
retry_count=task.retry_count or 0,
|
||||||
source_id=task.id,
|
source_id=task.id,
|
||||||
template_id=task.template_id,
|
template_id=task.template_id,
|
||||||
created_at=task.created_at,
|
created_at=task.created_at,
|
||||||
@@ -97,40 +126,66 @@ def _generation_task_to_project_response(task) -> ProjectTaskResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_status(status: str | None) -> str | None:
|
||||||
|
"""校验状态值合法性。"""
|
||||||
|
if status is None:
|
||||||
|
return None
|
||||||
|
valid = {"pending", "running", "completed", "failed", "cancelled"}
|
||||||
|
if status not in valid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"无效的状态筛选值: {status},允许值: {', '.join(sorted(valid))}",
|
||||||
|
)
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_page_size(page_size: int) -> int:
|
||||||
|
if page_size <= 0:
|
||||||
|
return DEFAULT_PAGE_SIZE
|
||||||
|
if page_size > MAX_PAGE_SIZE:
|
||||||
|
return MAX_PAGE_SIZE
|
||||||
|
return page_size
|
||||||
|
|
||||||
|
|
||||||
# ── 用户级端点(放在项目级端点之前,避免路由冲突) ──
|
# ── 用户级端点(放在项目级端点之前,避免路由冲突) ──
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks", response_model=ListTasksResponse)
|
@router.get("/tasks", response_model=ListTasksResponse)
|
||||||
def list_user_tasks(
|
def list_user_tasks(
|
||||||
|
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
|
||||||
|
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
|
||||||
|
page: int = Query(1, ge=1, description="页码,从1开始"),
|
||||||
|
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||||
) -> ListTasksResponse:
|
) -> ListTasksResponse:
|
||||||
"""用户级任务列表(跨 project),合并 ingest + generation 任务。"""
|
"""用户级任务列表(跨 project),支持状态/类型筛选和分页。"""
|
||||||
|
status = _validate_status(status)
|
||||||
|
page_size = _clamp_page_size(page_size)
|
||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
items: list[UserTaskResponse] = []
|
items: list[UserTaskResponse] = []
|
||||||
|
|
||||||
for task in generation_task_repository.list_by_user(user_id):
|
# 生成任务
|
||||||
items.append(
|
if task_type is None or task_type == "generation":
|
||||||
UserTaskResponse(
|
gen_result = generation_task_repository.list_by_user_filtered(
|
||||||
id=f"generation:{task.id}",
|
user_id,
|
||||||
task_type="generation",
|
status=status,
|
||||||
project_id=task.project_id,
|
limit=page_size + 1, # 多取一条判断是否还有下一页(简单起见这里用offset)
|
||||||
template_id=task.template_id,
|
offset=offset,
|
||||||
status=_status_value(task.status),
|
|
||||||
progress=task.progress,
|
|
||||||
current_step=_generation_step(task),
|
|
||||||
error_message=task.error_message,
|
|
||||||
user_message=_humanize_task_error(task.error_message),
|
|
||||||
retryable=_status_value(task.status) == "failed",
|
|
||||||
source_id=task.id,
|
|
||||||
created_at=task.created_at,
|
|
||||||
updated_at=task.completed_at or task.started_at or task.created_at,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
for task in gen_result:
|
||||||
|
items.append(_generation_task_to_user_response(task))
|
||||||
|
|
||||||
|
# 按时间倒序
|
||||||
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
|
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
|
||||||
return ListTasksResponse(items=items)
|
|
||||||
|
# 总数(仅generation,ingest暂不计入总数以保持简单)
|
||||||
|
total = generation_task_repository.count_by_user_filtered(user_id, status=status)
|
||||||
|
|
||||||
|
return ListTasksResponse(items=items[:page_size], total=total)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/retry", response_model=UserTaskResponse)
|
@router.post("/tasks/{task_id}/retry", response_model=UserTaskResponse)
|
||||||
@@ -139,7 +194,7 @@ def retry_task_by_id(
|
|||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||||
) -> UserTaskResponse:
|
) -> UserTaskResponse:
|
||||||
"""简化重试:通过 task_id 直接重试失败的生成任务。"""
|
"""原地重试失败的生成任务(复用同一个task_id,retry_count+1)。"""
|
||||||
task = generation_task_repository.get(task_id)
|
task = generation_task_repository.get(task_id)
|
||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail="Generation task not found")
|
raise HTTPException(status_code=404, detail="Generation task not found")
|
||||||
@@ -149,6 +204,7 @@ def retry_task_by_id(
|
|||||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||||
|
|
||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
|
|
||||||
# 预检查
|
# 预检查
|
||||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||||
global_pending = generation_task_repository.count_pending_total()
|
global_pending = generation_task_repository.count_pending_total()
|
||||||
@@ -163,20 +219,11 @@ def retry_task_by_id(
|
|||||||
detail="系统繁忙,请稍后再试",
|
detail="系统繁忙,请稍后再试",
|
||||||
)
|
)
|
||||||
|
|
||||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
# 原地重试
|
||||||
retried = use_case.execute(
|
use_case = RetryGenerationTaskUseCase(generation_task_repository)
|
||||||
CreateGenerationTaskCommand(
|
retried = use_case.execute(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,
|
|
||||||
template_id=task.template_id,
|
|
||||||
asset_ids=task.asset_ids,
|
|
||||||
title_ids=task.title_ids,
|
|
||||||
voice_ids=task.voice_ids,
|
|
||||||
created_by_user_id=user_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
if not safe_enqueue_generation_task(
|
if not safe_enqueue_generation_task(
|
||||||
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
|
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
|
||||||
@@ -192,18 +239,8 @@ def retry_task_by_id(
|
|||||||
status_code=503,
|
status_code=503,
|
||||||
detail="系统繁忙,请稍后再试",
|
detail="系统繁忙,请稍后再试",
|
||||||
) from None
|
) from None
|
||||||
return UserTaskResponse(
|
|
||||||
id=f"generation:{retried.id}",
|
return _generation_task_to_user_response(retried)
|
||||||
task_type="generation",
|
|
||||||
project_id=retried.project_id,
|
|
||||||
template_id=retried.template_id,
|
|
||||||
status=_status_value(retried.status),
|
|
||||||
progress=retried.progress,
|
|
||||||
current_step=_generation_step(retried),
|
|
||||||
source_id=retried.id,
|
|
||||||
created_at=retried.created_at,
|
|
||||||
updated_at=retried.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 项目级端点 ──
|
# ── 项目级端点 ──
|
||||||
@@ -212,37 +249,64 @@ def retry_task_by_id(
|
|||||||
@router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse)
|
@router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse)
|
||||||
def list_project_tasks(
|
def list_project_tasks(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
|
||||||
|
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
|
||||||
|
page: int = Query(1, ge=1, description="页码,从1开始"),
|
||||||
|
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
project_repository: Any = Depends(get_project_repository),
|
project_repository: Any = Depends(get_project_repository),
|
||||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||||
) -> ListProjectTasksResponse:
|
) -> ListProjectTasksResponse:
|
||||||
|
"""项目级任务列表,支持状态/类型筛选和分页。"""
|
||||||
project = project_repository.find_by_id(project_id)
|
project = project_repository.find_by_id(project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
status = _validate_status(status)
|
||||||
|
page_size = _clamp_page_size(page_size)
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
items: list[ProjectTaskResponse] = []
|
items: list[ProjectTaskResponse] = []
|
||||||
for job in ingest_job_repository.list_by_project(project_id):
|
|
||||||
items.append(
|
# 导入任务
|
||||||
ProjectTaskResponse(
|
if task_type is None or task_type == "ingest":
|
||||||
id=f"ingest:{job.id}",
|
for job in ingest_job_repository.list_by_project(project_id):
|
||||||
task_type="ingest",
|
if status and _status_value(job.status) != status:
|
||||||
project_id=job.project_id,
|
continue
|
||||||
status=_status_value(job.status),
|
items.append(
|
||||||
progress=100.0 if _status_value(job.status) == "completed" else 0.0,
|
ProjectTaskResponse(
|
||||||
current_step=_ingest_step(job),
|
id=f"ingest:{job.id}",
|
||||||
error_message=job.error_message,
|
task_type="ingest",
|
||||||
user_message=_humanize_task_error(job.error_message),
|
project_id=job.project_id,
|
||||||
retryable=_status_value(job.status) == "failed",
|
status=_status_value(job.status),
|
||||||
source_id=job.id,
|
progress=100.0 if _status_value(job.status) == "completed" else 0.0,
|
||||||
created_at=job.created_at,
|
current_step=_ingest_step(job),
|
||||||
updated_at=job.updated_at,
|
error_message=job.error_message,
|
||||||
|
user_message=_humanize_task_error(job.error_message),
|
||||||
|
retryable=_status_value(job.status) == "failed",
|
||||||
|
source_id=job.id,
|
||||||
|
created_at=job.created_at,
|
||||||
|
updated_at=job.updated_at,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 生成任务
|
||||||
|
if task_type is None or task_type == "generation":
|
||||||
|
gen_items = generation_task_repository.list_by_project_filtered(
|
||||||
|
project_id,
|
||||||
|
status=status,
|
||||||
|
limit=page_size + 1,
|
||||||
|
offset=offset,
|
||||||
)
|
)
|
||||||
for task in generation_task_repository.list_by_project(project_id):
|
for task in gen_items:
|
||||||
items.append(_generation_task_to_project_response(task))
|
items.append(_generation_task_to_project_response(task))
|
||||||
|
|
||||||
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
|
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
|
||||||
return ListProjectTasksResponse(items=items)
|
|
||||||
|
total = generation_task_repository.count_by_project_filtered(project_id, status=status)
|
||||||
|
|
||||||
|
return ListProjectTasksResponse(items=items[:page_size], total=total)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_type}/{source_id}/retry", response_model=ProjectTaskResponse)
|
@router.post("/tasks/{task_type}/{source_id}/retry", response_model=ProjectTaskResponse)
|
||||||
@@ -253,6 +317,7 @@ def retry_project_task(
|
|||||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||||
) -> ProjectTaskResponse:
|
) -> ProjectTaskResponse:
|
||||||
|
"""项目级任务重试。"""
|
||||||
if task_type == "generation":
|
if task_type == "generation":
|
||||||
task = generation_task_repository.get(source_id)
|
task = generation_task_repository.get(source_id)
|
||||||
if task is None:
|
if task is None:
|
||||||
@@ -261,6 +326,7 @@ def retry_project_task(
|
|||||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||||
|
|
||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
|
|
||||||
# 预检查
|
# 预检查
|
||||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||||
global_pending = generation_task_repository.count_pending_total()
|
global_pending = generation_task_repository.count_pending_total()
|
||||||
@@ -275,20 +341,10 @@ def retry_project_task(
|
|||||||
detail="系统繁忙,请稍后再试",
|
detail="系统繁忙,请稍后再试",
|
||||||
)
|
)
|
||||||
|
|
||||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
# 原地重试
|
||||||
retried = use_case.execute(
|
use_case = RetryGenerationTaskUseCase(generation_task_repository)
|
||||||
CreateGenerationTaskCommand(
|
retried = use_case.execute(source_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,
|
|
||||||
template_id=task.template_id,
|
|
||||||
asset_ids=task.asset_ids,
|
|
||||||
title_ids=task.title_ids,
|
|
||||||
voice_ids=task.voice_ids,
|
|
||||||
created_by_user_id=user_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
if not safe_enqueue_generation_task(
|
if not safe_enqueue_generation_task(
|
||||||
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
|
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
|
||||||
@@ -305,6 +361,7 @@ def retry_project_task(
|
|||||||
detail="系统繁忙,请稍后再试",
|
detail="系统繁忙,请稍后再试",
|
||||||
) from None
|
) from None
|
||||||
return _generation_task_to_project_response(retried)
|
return _generation_task_to_project_response(retried)
|
||||||
|
|
||||||
if task_type == "ingest":
|
if task_type == "ingest":
|
||||||
job = ingest_job_repository.get(source_id)
|
job = ingest_job_repository.get(source_id)
|
||||||
if job is None:
|
if job is None:
|
||||||
|
|||||||
@@ -8,13 +8,16 @@ from app.auth import AuthenticatedUser, get_current_user
|
|||||||
from app.dependencies import get_db_session
|
from app.dependencies import get_db_session
|
||||||
from app.schemas.template import (
|
from app.schemas.template import (
|
||||||
CategoryResponse,
|
CategoryResponse,
|
||||||
|
CopyTemplateRequest,
|
||||||
CreateCategoryRequest,
|
CreateCategoryRequest,
|
||||||
CreateTemplateRequest,
|
CreateTemplateRequest,
|
||||||
GenerateWarningResponse,
|
GenerateWarningResponse,
|
||||||
ListCategoriesResponse,
|
ListCategoriesResponse,
|
||||||
|
ListTagsResponse,
|
||||||
ListTemplatesResponse,
|
ListTemplatesResponse,
|
||||||
SegmentResponse,
|
SegmentResponse,
|
||||||
TemplateResponse,
|
TemplateResponse,
|
||||||
|
TemplateUsageResponse,
|
||||||
ToggleFavoriteResponse,
|
ToggleFavoriteResponse,
|
||||||
UpdateTemplateRequest,
|
UpdateTemplateRequest,
|
||||||
ValidateTemplateRequest,
|
ValidateTemplateRequest,
|
||||||
@@ -27,19 +30,24 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository
|
from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository
|
||||||
from packages.application.template.commands import (
|
from packages.application.template.commands import (
|
||||||
|
CopyTemplateCommand,
|
||||||
CreateCategoryCommand,
|
CreateCategoryCommand,
|
||||||
CreateTemplateCommand,
|
CreateTemplateCommand,
|
||||||
|
ListTemplatesFilter,
|
||||||
SegmentCommand,
|
SegmentCommand,
|
||||||
UpdateTemplateCommand,
|
UpdateTemplateCommand,
|
||||||
ValidateTemplateCommand,
|
ValidateTemplateCommand,
|
||||||
)
|
)
|
||||||
from packages.application.template.use_cases import (
|
from packages.application.template.use_cases import (
|
||||||
|
CopyTemplateUseCase,
|
||||||
|
CountTemplatesUseCase,
|
||||||
CreateCategoryUseCase,
|
CreateCategoryUseCase,
|
||||||
CreateTemplateUseCase,
|
CreateTemplateUseCase,
|
||||||
DeleteCategoryUseCase,
|
DeleteCategoryUseCase,
|
||||||
DeleteTemplateUseCase,
|
DeleteTemplateUseCase,
|
||||||
GetTemplateUseCase,
|
GetTemplateUseCase,
|
||||||
ListCategoriesUseCase,
|
ListCategoriesUseCase,
|
||||||
|
ListTagsUseCase,
|
||||||
ListTemplatesUseCase,
|
ListTemplatesUseCase,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
UpdateTemplateUseCase,
|
UpdateTemplateUseCase,
|
||||||
@@ -67,7 +75,7 @@ def _segment_to_response(seg) -> SegmentResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _to_response(template) -> TemplateResponse:
|
def _to_response(template, usage_count: int = 0) -> TemplateResponse:
|
||||||
return TemplateResponse(
|
return TemplateResponse(
|
||||||
id=template.id,
|
id=template.id,
|
||||||
user_id=template.user_id,
|
user_id=template.user_id,
|
||||||
@@ -81,6 +89,7 @@ def _to_response(template) -> TemplateResponse:
|
|||||||
estimated_duration=template.estimated_duration,
|
estimated_duration=template.estimated_duration,
|
||||||
segments=[_segment_to_response(s) for s in getattr(template, "segments", [])],
|
segments=[_segment_to_response(s) for s in getattr(template, "segments", [])],
|
||||||
is_active=template.is_active,
|
is_active=template.is_active,
|
||||||
|
usage_count=usage_count,
|
||||||
created_at=template.created_at,
|
created_at=template.created_at,
|
||||||
updated_at=template.updated_at,
|
updated_at=template.updated_at,
|
||||||
)
|
)
|
||||||
@@ -93,19 +102,36 @@ def _to_response(template) -> TemplateResponse:
|
|||||||
def list_templates(
|
def list_templates(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=200),
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
category: str | None = Query(None, description="按分类筛选"),
|
||||||
|
tag: str | None = Query(None, description="按标签筛选"),
|
||||||
|
keyword: str | None = Query(None, description="按名称关键词搜索"),
|
||||||
|
mode: str | None = Query(None, description="按剪辑模式筛选"),
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||||
) -> ListTemplatesResponse:
|
) -> ListTemplatesResponse:
|
||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
try:
|
try:
|
||||||
|
tpl_filter = ListTemplatesFilter(
|
||||||
|
category=category,
|
||||||
|
tag=tag,
|
||||||
|
keyword=keyword,
|
||||||
|
mode=mode,
|
||||||
|
)
|
||||||
use_case = ListTemplatesUseCase(template_repository)
|
use_case = ListTemplatesUseCase(template_repository)
|
||||||
templates = use_case.execute(user_id, skip=skip, limit=limit)
|
templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter)
|
||||||
total = template_repository.count_by_user(user_id)
|
count_use_case = CountTemplatesUseCase(template_repository)
|
||||||
|
total = count_use_case.execute(user_id, filter=tpl_filter)
|
||||||
|
|
||||||
|
# 批量查询使用次数
|
||||||
|
items = []
|
||||||
|
for t in templates:
|
||||||
|
usage = template_repository.get_usage_count(t.id)
|
||||||
|
items.append(_to_response(t, usage_count=usage))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("list_templates 查询失败: user_id=%s", user_id)
|
logger.exception("list_templates 查询失败: user_id=%s", user_id)
|
||||||
return ListTemplatesResponse(items=[], total=0)
|
return ListTemplatesResponse(items=[], total=0)
|
||||||
return ListTemplatesResponse(
|
return ListTemplatesResponse(
|
||||||
items=[_to_response(t) for t in templates],
|
items=items,
|
||||||
total=total,
|
total=total,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -120,12 +146,13 @@ def get_template(
|
|||||||
try:
|
try:
|
||||||
use_case = GetTemplateUseCase(template_repository)
|
use_case = GetTemplateUseCase(template_repository)
|
||||||
template = use_case.execute(template_id, user_id)
|
template = use_case.execute(template_id, user_id)
|
||||||
|
usage = template_repository.get_usage_count(template_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("get_template 查询失败: template_id=%s", template_id)
|
logger.exception("get_template 查询失败: template_id=%s", template_id)
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败")
|
||||||
if template is None:
|
if template is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||||
return _to_response(template)
|
return _to_response(template, usage_count=usage)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -206,7 +233,7 @@ def update_template(
|
|||||||
return _to_response(template)
|
return _to_response(template)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||||
def delete_template(
|
def delete_template(
|
||||||
template_id: str,
|
template_id: str,
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
@@ -217,7 +244,48 @@ def delete_template(
|
|||||||
deleted = use_case.execute(template_id, user_id)
|
deleted = use_case.execute(template_id, user_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||||
return Response(status_code=204)
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{template_id}/copy", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def copy_template(
|
||||||
|
template_id: str,
|
||||||
|
request: CopyTemplateRequest,
|
||||||
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||||
|
) -> TemplateResponse:
|
||||||
|
"""复制模板(含所有片段配置)"""
|
||||||
|
user_id = authenticated_user.user.id
|
||||||
|
command = CopyTemplateCommand(
|
||||||
|
template_id=template_id,
|
||||||
|
user_id=user_id,
|
||||||
|
new_name=request.new_name,
|
||||||
|
)
|
||||||
|
use_case = CopyTemplateUseCase(template_repository)
|
||||||
|
try:
|
||||||
|
template = use_case.execute(command)
|
||||||
|
except NotFoundError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||||
|
return _to_response(template)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{template_id}/usage", response_model=TemplateUsageResponse)
|
||||||
|
def get_template_usage(
|
||||||
|
template_id: str,
|
||||||
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||||
|
) -> TemplateUsageResponse:
|
||||||
|
"""获取模板使用次数(关联的剪辑计划数量)"""
|
||||||
|
user_id = authenticated_user.user.id
|
||||||
|
# 鉴权:确保模板存在且属于当前用户
|
||||||
|
use_case = GetTemplateUseCase(template_repository)
|
||||||
|
template = use_case.execute(template_id, user_id)
|
||||||
|
if template is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||||
|
usage = template_repository.get_usage_count(template_id)
|
||||||
|
return TemplateUsageResponse(template_id=template_id, usage_count=usage)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{template_id}/toggle-favorite", response_model=ToggleFavoriteResponse)
|
@router.post("/{template_id}/toggle-favorite", response_model=ToggleFavoriteResponse)
|
||||||
@@ -307,7 +375,7 @@ def create_category(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||||
def delete_category(
|
def delete_category(
|
||||||
category_id: str,
|
category_id: str,
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
@@ -319,3 +387,22 @@ def delete_category(
|
|||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tags ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tags/list", response_model=ListTagsResponse)
|
||||||
|
def list_tags(
|
||||||
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||||
|
) -> ListTagsResponse:
|
||||||
|
"""获取用户所有模板标签(去重排序)"""
|
||||||
|
user_id = authenticated_user.user.id
|
||||||
|
try:
|
||||||
|
use_case = ListTagsUseCase(template_repository)
|
||||||
|
tags = use_case.execute(user_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("list_tags 查询失败: user_id=%s", user_id)
|
||||||
|
return ListTagsResponse(items=[])
|
||||||
|
return ListTagsResponse(items=tags)
|
||||||
|
|||||||
Regular → Executable
+45
-11
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.api.routes._helpers import get_user_plan
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.dependencies import get_db_session, get_user_repository
|
from app.dependencies import get_db_session, get_user_repository
|
||||||
from app.schemas.title_library import (
|
from app.schemas.title_library import (
|
||||||
@@ -16,13 +17,18 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from packages.adapters.sqlalchemy_impl.title_library_repository import SQLAlchemyTitleLibraryRepository
|
from packages.adapters.sqlalchemy_impl.title_library_repository import SQLAlchemyTitleLibraryRepository
|
||||||
from packages.application.title_library.commands import CreateTitleLibraryCommand, UpdateTitleLibraryCommand
|
from packages.application.title_library.commands import (
|
||||||
|
CreateTitleLibraryCommand,
|
||||||
|
PickTitleCommand,
|
||||||
|
UpdateTitleLibraryCommand,
|
||||||
|
)
|
||||||
from packages.application.title_library.use_cases import (
|
from packages.application.title_library.use_cases import (
|
||||||
CreateTitleLibraryUseCase,
|
CreateTitleLibraryUseCase,
|
||||||
DeleteTitleLibraryUseCase,
|
DeleteTitleLibraryUseCase,
|
||||||
GetTitleLibraryUseCase,
|
GetTitleLibraryUseCase,
|
||||||
ListTitleLibraryUseCase,
|
ListTitleLibraryUseCase,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
|
PickTitleUseCase,
|
||||||
QuotaExceededError,
|
QuotaExceededError,
|
||||||
UpdateTitleLibraryUseCase,
|
UpdateTitleLibraryUseCase,
|
||||||
)
|
)
|
||||||
@@ -51,13 +57,6 @@ def _to_response(item) -> TitleLibraryItemResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
|
|
||||||
user = user_repository.find_by_id(user_id)
|
|
||||||
if user is None:
|
|
||||||
return "free"
|
|
||||||
return getattr(user, "subscription_plan", "free") or "free"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ListTitleLibraryResponse)
|
@router.get("", response_model=ListTitleLibraryResponse)
|
||||||
def list_titles(
|
def list_titles(
|
||||||
category: Optional[str] = Query(None),
|
category: Optional[str] = Query(None),
|
||||||
@@ -76,6 +75,41 @@ def list_titles(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pick", response_model=TitleLibraryItemResponse)
|
||||||
|
def pick_title(
|
||||||
|
category: Optional[str] = Query(None, description="按分类筛选,不填则从全部标题中选"),
|
||||||
|
exclude_ids: Optional[str] = Query(
|
||||||
|
None,
|
||||||
|
description="排除的标题ID(逗号分隔),用于批量生成时避免重复",
|
||||||
|
),
|
||||||
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
|
||||||
|
) -> TitleLibraryItemResponse:
|
||||||
|
"""智能选择一个标题。
|
||||||
|
|
||||||
|
策略:优先使用次数少的,从最少的前5个中随机选一个,兼顾公平和多样性。
|
||||||
|
"""
|
||||||
|
user_id = authenticated_user.user.id
|
||||||
|
exclude_list: list[str] = []
|
||||||
|
if exclude_ids:
|
||||||
|
exclude_list = [t.strip() for t in exclude_ids.split(",") if t.strip()]
|
||||||
|
|
||||||
|
use_case = PickTitleUseCase(title_repository)
|
||||||
|
item = use_case.execute(
|
||||||
|
PickTitleCommand(
|
||||||
|
user_id=user_id,
|
||||||
|
category=category,
|
||||||
|
exclude_ids=exclude_list,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="标题库为空,请先添加标题",
|
||||||
|
)
|
||||||
|
return _to_response(item)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{title_id}", response_model=TitleLibraryItemResponse)
|
@router.get("/{title_id}", response_model=TitleLibraryItemResponse)
|
||||||
def get_title(
|
def get_title(
|
||||||
title_id: str,
|
title_id: str,
|
||||||
@@ -98,7 +132,7 @@ def create_title(
|
|||||||
user_repository: UserRepository = Depends(get_user_repository),
|
user_repository: UserRepository = Depends(get_user_repository),
|
||||||
) -> TitleLibraryItemResponse:
|
) -> TitleLibraryItemResponse:
|
||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
plan_name = _get_user_plan(user_id, user_repository)
|
plan_name = get_user_plan(user_id, user_repository)
|
||||||
command = CreateTitleLibraryCommand(
|
command = CreateTitleLibraryCommand(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
name=request.name,
|
name=request.name,
|
||||||
@@ -143,7 +177,7 @@ def update_title(
|
|||||||
return _to_response(item)
|
return _to_response(item)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||||
def delete_title(
|
def delete_title(
|
||||||
title_id: str,
|
title_id: str,
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
@@ -154,4 +188,4 @@ def delete_title(
|
|||||||
deleted = use_case.execute(title_id, user_id)
|
deleted = use_case.execute(title_id, user_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
|
||||||
return Response(status_code=204)
|
return
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from packages.application.voice_library.use_cases import (
|
|||||||
CreateVoiceLibraryUseCase,
|
CreateVoiceLibraryUseCase,
|
||||||
QuotaExceededError,
|
QuotaExceededError,
|
||||||
)
|
)
|
||||||
|
from packages.domain.voice_presets import list_voices
|
||||||
from packages.ports.user_repository import UserRepository
|
from packages.ports.user_repository import UserRepository
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -53,6 +54,34 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/presets", summary="获取预设音色列表")
|
||||||
|
def list_preset_voices(
|
||||||
|
gender: Optional[str] = Query(None, description="按性别筛选: male/female/child"),
|
||||||
|
style: Optional[str] = Query(None, description="按风格筛选: stable/lively/customer_service/narration/news/story"),
|
||||||
|
keyword: Optional[str] = Query(None, description="按关键词搜索"),
|
||||||
|
_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
) -> list[dict]:
|
||||||
|
"""获取可用的预设音色列表。
|
||||||
|
|
||||||
|
用于配音功能的音色选择。
|
||||||
|
"""
|
||||||
|
voices = list_voices(gender=gender, style=style, keyword=keyword)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"voice_id": v.voice_id,
|
||||||
|
"name": v.name,
|
||||||
|
"gender": v.gender.value,
|
||||||
|
"style": v.style.value,
|
||||||
|
"description": v.description,
|
||||||
|
"default_speed": v.default_speed,
|
||||||
|
"default_pitch": v.default_pitch,
|
||||||
|
"sample_rate": v.sample_rate,
|
||||||
|
"language": v.language,
|
||||||
|
}
|
||||||
|
for v in voices
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _get_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTTSJobRepository:
|
def _get_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTTSJobRepository:
|
||||||
return SQLAlchemyTTSJobRepository(session)
|
return SQLAlchemyTTSJobRepository(session)
|
||||||
|
|
||||||
@@ -241,7 +270,7 @@ def get_tts_job_status(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||||
def delete_tts_job(
|
def delete_tts_job(
|
||||||
job_id: str,
|
job_id: str,
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
@@ -253,7 +282,7 @@ def delete_tts_job(
|
|||||||
deleted = use_case.execute(job_id, user_id)
|
deleted = use_case.execute(job_id, user_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||||
return Response(status_code=204)
|
return
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -372,10 +401,10 @@ async def tts_websocket_stream(
|
|||||||
streaming_service = TTSStreamingService(cosyvoice_service)
|
streaming_service = TTSStreamingService(cosyvoice_service)
|
||||||
await streaming_service.synthesize_and_stream(websocket, params)
|
await streaming_service.synthesize_and_stream(websocket, params)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
logger.info("WebSocket 客户端断开连接")
|
logger.info("WebSocket 客户端主动断开连接")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"WebSocket 流式合成异常: {e}", exc_info=True)
|
logger.error(f"WebSocket 流式合成异常: {e}", exc_info=True)
|
||||||
try:
|
try:
|
||||||
await websocket.send_json({"type": "error", "message": f"服务异常: {e}"})
|
await websocket.send_json({"type": "error", "message": f"服务异常: {e}"})
|
||||||
except Exception:
|
except Exception as send_err:
|
||||||
pass
|
logger.warning("WebSocket 错误消息发送失败(连接可能已断开): %s", send_err)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Annotated, Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.api.routes._helpers import require_project_and_library
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.core.celery_app import celery_app
|
from app.core.celery_app import celery_app
|
||||||
@@ -17,12 +18,11 @@ from app.schemas.upload import (
|
|||||||
DirectUploadCompleteResponse,
|
DirectUploadCompleteResponse,
|
||||||
DirectUploadPrepareRequest,
|
DirectUploadPrepareRequest,
|
||||||
DirectUploadPrepareResponse,
|
DirectUploadPrepareResponse,
|
||||||
UploadAssetRequest,
|
|
||||||
UploadAssetResponse,
|
UploadAssetResponse,
|
||||||
)
|
)
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
|
|
||||||
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
|
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -80,21 +80,6 @@ def _validate_mime_type(content_type: str | None) -> str:
|
|||||||
return base_type
|
return base_type
|
||||||
|
|
||||||
|
|
||||||
def _require_project_and_library(
|
|
||||||
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:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
|
||||||
|
|
||||||
libraries = asset_library_repository.find_by_project(project_id)
|
|
||||||
if not any(item.id == library_id for item in libraries):
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
|
|
||||||
|
|
||||||
|
|
||||||
def _submit_ingest_job(
|
def _submit_ingest_job(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
library_id: str,
|
library_id: str,
|
||||||
@@ -135,7 +120,7 @@ async def prepare_direct_upload(
|
|||||||
# P2-5: 服务端验证 MIME 类型
|
# P2-5: 服务端验证 MIME 类型
|
||||||
validated_content_type = _validate_mime_type(request.content_type)
|
validated_content_type = _validate_mime_type(request.content_type)
|
||||||
|
|
||||||
_require_project_and_library(
|
require_project_and_library(
|
||||||
request.project_id,
|
request.project_id,
|
||||||
request.library_id,
|
request.library_id,
|
||||||
project_repository,
|
project_repository,
|
||||||
@@ -183,7 +168,7 @@ async def complete_direct_upload(
|
|||||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||||
) -> DirectUploadCompleteResponse:
|
) -> DirectUploadCompleteResponse:
|
||||||
"""确认浏览器直传完成并创建导入任务。"""
|
"""确认浏览器直传完成并创建导入任务。"""
|
||||||
_require_project_and_library(
|
require_project_and_library(
|
||||||
request.project_id,
|
request.project_id,
|
||||||
request.library_id,
|
request.library_id,
|
||||||
project_repository,
|
project_repository,
|
||||||
@@ -252,7 +237,7 @@ async def upload_asset(
|
|||||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||||
) -> UploadAssetResponse:
|
) -> UploadAssetResponse:
|
||||||
"""上传素材文件并触发导入流水线。"""
|
"""上传素材文件并触发导入流水线。"""
|
||||||
_require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
|
require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
|
||||||
|
|
||||||
# ── 素材去重检测:上传前检查同素材库 + 同 file_hash ──
|
# ── 素材去重检测:上传前检查同素材库 + 同 file_hash ──
|
||||||
if file_hash:
|
if file_hash:
|
||||||
|
|||||||
Executable
+179
@@ -0,0 +1,179 @@
|
|||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.api.routes._helpers import check_project_access
|
||||||
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
|
from app.core.celery_app import celery_app
|
||||||
|
from app.core.storage import OSSStorageService, get_storage_service
|
||||||
|
from app.dependencies import get_generated_video_repository
|
||||||
|
from app.schemas.video_center import (
|
||||||
|
BatchDownloadRequest,
|
||||||
|
BatchDownloadResponse,
|
||||||
|
ListVideosResponse,
|
||||||
|
UpdateVideoReviewRequest,
|
||||||
|
VideoItemResponse,
|
||||||
|
)
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from packages.application import (
|
||||||
|
GetGeneratedVideoUseCase,
|
||||||
|
GetVideosByIdsUseCase,
|
||||||
|
ListGeneratedVideosPaginatedUseCase,
|
||||||
|
UpdateVideoReviewStatusUseCase,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoItemResponse:
|
||||||
|
download_url = None
|
||||||
|
if storage and item.file_url:
|
||||||
|
try:
|
||||||
|
download_url = storage.get_download_url(item.file_url)
|
||||||
|
except Exception:
|
||||||
|
download_url = item.file_url
|
||||||
|
return VideoItemResponse(
|
||||||
|
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,
|
||||||
|
status=item.status,
|
||||||
|
review_status=item.review_status,
|
||||||
|
generation_params=item.generation_params,
|
||||||
|
download_url=download_url,
|
||||||
|
generated_at=item.generated_at.isoformat() if hasattr(item, "generated_at") and item.generated_at else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/videos", response_model=ListVideosResponse)
|
||||||
|
def list_videos(
|
||||||
|
project_id: str | None = Query(None, description="项目ID,不传则返回所有项目"),
|
||||||
|
status: str | None = Query(None, description="按状态筛选"),
|
||||||
|
review_status: str | None = Query(None, description="按复核状态筛选"),
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
repo=Depends(get_generated_video_repository),
|
||||||
|
storage: OSSStorageService = Depends(get_storage_service),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""成片列表,支持分页、按项目/状态/复核状态筛选。"""
|
||||||
|
use_case = ListGeneratedVideosPaginatedUseCase(repo)
|
||||||
|
items, total = use_case.execute(
|
||||||
|
project_id=project_id,
|
||||||
|
status=status,
|
||||||
|
review_status=review_status,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return ListVideosResponse(
|
||||||
|
items=[_to_video_response(item, storage) for item in items],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/videos/{video_id}", response_model=VideoItemResponse)
|
||||||
|
def get_video(
|
||||||
|
video_id: str,
|
||||||
|
repo=Depends(get_generated_video_repository),
|
||||||
|
storage: OSSStorageService = Depends(get_storage_service),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""获取单个成片详情。"""
|
||||||
|
use_case = GetGeneratedVideoUseCase(repo)
|
||||||
|
item = use_case.execute(video_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Video not found")
|
||||||
|
return _to_video_response(item, storage)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/videos/{video_id}/review", response_model=VideoItemResponse)
|
||||||
|
def update_video_review_status(
|
||||||
|
video_id: str,
|
||||||
|
request: UpdateVideoReviewRequest,
|
||||||
|
repo=Depends(get_generated_video_repository),
|
||||||
|
storage: OSSStorageService = Depends(get_storage_service),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""更新成片复核状态:pending_review / approved / rejected。"""
|
||||||
|
use_case = UpdateVideoReviewStatusUseCase(repo)
|
||||||
|
item = use_case.execute(video_id, request.review_status)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Video not found")
|
||||||
|
logger.info("Video %s review status updated to %s by user %s", video_id, request.review_status, current_user.user_id)
|
||||||
|
return _to_video_response(item, storage)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/videos/batch-download", response_model=BatchDownloadResponse)
|
||||||
|
def batch_download_videos(
|
||||||
|
request: BatchDownloadRequest,
|
||||||
|
repo=Depends(get_generated_video_repository),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""批量下载成片,异步打包 zip。
|
||||||
|
|
||||||
|
传入 video_ids 列表,创建一个批量下载任务,任务完成后返回 zip 下载链接。
|
||||||
|
"""
|
||||||
|
if not request.video_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="video_ids cannot be empty")
|
||||||
|
if len(request.video_ids) > 50:
|
||||||
|
raise HTTPException(status_code=400, detail="Maximum 50 videos per batch download")
|
||||||
|
|
||||||
|
# 校验视频都存在
|
||||||
|
use_case = GetVideosByIdsUseCase(repo)
|
||||||
|
videos = use_case.execute(request.video_ids)
|
||||||
|
if len(videos) != len(request.video_ids):
|
||||||
|
raise HTTPException(status_code=404, detail="Some videos not found")
|
||||||
|
|
||||||
|
# 发送 celery 任务
|
||||||
|
task = celery_app.send_task(
|
||||||
|
"worker.batch_download_videos",
|
||||||
|
args=[request.video_ids, current_user.user_id],
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Batch download job created: %s, videos=%d", task.id, len(request.video_ids))
|
||||||
|
return BatchDownloadResponse(job_id=task.id, status="pending")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/videos/batch-download/{job_id}", response_model=BatchDownloadResponse)
|
||||||
|
def get_batch_download_status(
|
||||||
|
job_id: str,
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""查询批量下载任务状态。"""
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
|
||||||
|
task = AsyncResult(job_id, app=celery_app)
|
||||||
|
|
||||||
|
status_map = {
|
||||||
|
"PENDING": "pending",
|
||||||
|
"STARTED": "running",
|
||||||
|
"SUCCESS": "success",
|
||||||
|
"FAILURE": "failed",
|
||||||
|
"RETRY": "pending",
|
||||||
|
"REVOKED": "cancelled",
|
||||||
|
}
|
||||||
|
api_status = status_map.get(task.state, "pending")
|
||||||
|
|
||||||
|
download_url = None
|
||||||
|
if task.state == "SUCCESS" and task.result:
|
||||||
|
if isinstance(task.result, dict):
|
||||||
|
download_url = task.result.get("download_url")
|
||||||
|
elif isinstance(task.result, str):
|
||||||
|
download_url = task.result
|
||||||
|
|
||||||
|
return BatchDownloadResponse(
|
||||||
|
job_id=job_id,
|
||||||
|
status=api_status,
|
||||||
|
download_url=download_url,
|
||||||
|
)
|
||||||
@@ -28,7 +28,6 @@ from packages.application.voice_clone.use_cases import (
|
|||||||
VoiceCloneNotRetryableError,
|
VoiceCloneNotRetryableError,
|
||||||
)
|
)
|
||||||
from packages.application.voice_clone.workflow import (
|
from packages.application.voice_clone.workflow import (
|
||||||
VoiceCloneWorkflowError,
|
|
||||||
VoiceCloneWorkflowService,
|
VoiceCloneWorkflowService,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -173,6 +172,7 @@ def get_voice_clone_status(
|
|||||||
"/{clone_id}",
|
"/{clone_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
response_model=None,
|
response_model=None,
|
||||||
|
response_class=Response,
|
||||||
)
|
)
|
||||||
def delete_voice_clone(
|
def delete_voice_clone(
|
||||||
clone_id: str,
|
clone_id: str,
|
||||||
@@ -185,7 +185,7 @@ def delete_voice_clone(
|
|||||||
deleted = use_case.execute(clone_id, user_id)
|
deleted = use_case.execute(clone_id, user_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
|
||||||
return Response(status_code=204)
|
return
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{clone_id}/retry", response_model=VoiceCloneProfileResponse)
|
@router.post("/{clone_id}/retry", response_model=VoiceCloneProfileResponse)
|
||||||
|
|||||||
Executable → Regular
+4
-10
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from app.api.routes._helpers import get_user_plan
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository
|
from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository
|
||||||
from app.schemas.voice import (
|
from app.schemas.voice import (
|
||||||
@@ -125,13 +126,6 @@ def _preset_to_unified_response(preset) -> UnifiedVoiceItemResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
|
|
||||||
user = user_repository.find_by_id(user_id)
|
|
||||||
if user is None:
|
|
||||||
return "free"
|
|
||||||
return getattr(user, "subscription_plan", "free") or "free"
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 统一配音列表(预置 + 克隆)====================
|
# ==================== 统一配音列表(预置 + 克隆)====================
|
||||||
|
|
||||||
|
|
||||||
@@ -271,7 +265,7 @@ def create_voice(
|
|||||||
sign_url=Depends(get_audio_url_signer),
|
sign_url=Depends(get_audio_url_signer),
|
||||||
) -> VoiceLibraryItemResponse:
|
) -> VoiceLibraryItemResponse:
|
||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
plan_name = _get_user_plan(user_id, user_repository)
|
plan_name = get_user_plan(user_id, user_repository)
|
||||||
command = CreateVoiceLibraryCommand(
|
command = CreateVoiceLibraryCommand(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
name=request.name,
|
name=request.name,
|
||||||
@@ -328,7 +322,7 @@ def update_voice(
|
|||||||
return _to_response(item, sign_url)
|
return _to_response(item, sign_url)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||||
def delete_voice(
|
def delete_voice(
|
||||||
voice_id: str,
|
voice_id: str,
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
@@ -339,4 +333,4 @@ def delete_voice(
|
|||||||
deleted = use_case.execute(voice_id, user_id)
|
deleted = use_case.execute(voice_id, user_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
||||||
return Response(status_code=204)
|
return
|
||||||
|
|||||||
+10
-10
@@ -19,27 +19,25 @@ class Settings(BaseSettings):
|
|||||||
# Container bind address; external expose is controlled by Docker/Nginx.
|
# Container bind address; external expose is controlled by Docker/Nginx.
|
||||||
API_HOST: str = "0.0.0.0" # nosec: B104
|
API_HOST: str = "0.0.0.0" # nosec: B104
|
||||||
API_PORT: int = 8000
|
API_PORT: int = 8000
|
||||||
API_PREFIX: str = "/api/v1"
|
|
||||||
|
|
||||||
DATABASE_URL: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
|
DATABASE_URL: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
|
||||||
DATABASE_POOL_SIZE: int = 20
|
DATABASE_POOL_SIZE: int = 20
|
||||||
DATABASE_MAX_OVERFLOW: int = 10 # 调整为合理值:pool_size(20) + max_overflow(10) = 最大30连接
|
DATABASE_MAX_OVERFLOW: int = 10 # 调整为合理值:pool_size(20) + max_overflow(10) = 最大30连接
|
||||||
DATABASE_POOL_TIMEOUT: int = 30
|
DATABASE_POOL_TIMEOUT: int = 30
|
||||||
DATABASE_POOL_RECYLE: int = 3600
|
DATABASE_POOL_RECYCLE: int = 3600
|
||||||
USE_IN_MEMORY_DB: bool = False
|
USE_IN_MEMORY_DB: bool = False
|
||||||
AUTO_CREATE_SCHEMA: bool = False
|
AUTO_CREATE_SCHEMA: bool = False
|
||||||
|
|
||||||
REDIS_URL: str = "redis://localhost:6379/0"
|
REDIS_URL: str = "redis://localhost:6379/0"
|
||||||
REDIS_MAX_CONNECTION: int = 50
|
|
||||||
ENABLE_REDIS_SESSIONS: bool = False
|
ENABLE_REDIS_SESSIONS: bool = False
|
||||||
|
|
||||||
# JWT secret key - MUST be set via environment variable, no default allowed
|
# JWT secret key - MUST be set via environment variable, no default allowed
|
||||||
JWT_SECRET_KEY: Optional[str] = None
|
JWT_SECRET_KEY: Optional[str] = None
|
||||||
# 旧的 JWT secret key(用于密钥轮换期间验证旧 token)
|
|
||||||
# 在密钥轮换时,先设置新密钥,旧密钥保留在此处直到所有旧 token 过期
|
# JWT 算法与过期时间(与 .env.example 对齐)
|
||||||
JWT_SECRET_KEY_OLD: Optional[str] = None
|
JWT_ALGORITHM: str = "HS256"
|
||||||
# 密钥轮换天数(到达此天数后建议更换密钥)
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
SECRET_ROTATION_DAYS: int = 90
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
||||||
|
|
||||||
@field_validator("JWT_SECRET_KEY", mode="before")
|
@field_validator("JWT_SECRET_KEY", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -75,7 +73,7 @@ class Settings(BaseSettings):
|
|||||||
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
|
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
|
||||||
|
|
||||||
# OSS 七牛云相关
|
# OSS 七牛云相关
|
||||||
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliiyuncs.com"
|
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliyuncs.com"
|
||||||
OSS_ACCESS_KEY_ID: str = ""
|
OSS_ACCESS_KEY_ID: str = ""
|
||||||
OSS_ACCESS_KEY_SECRET: str = ""
|
OSS_ACCESS_KEY_SECRET: str = ""
|
||||||
OSS_BUCKET_NAME: str = "xiaoxia-autocut"
|
OSS_BUCKET_NAME: str = "xiaoxia-autocut"
|
||||||
@@ -106,9 +104,11 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS: int = 900
|
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS: int = 900
|
||||||
|
|
||||||
LOG_LEVEL: str = "INFO"
|
|
||||||
CORS_ORIGINS_RAW: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
|
CORS_ORIGINS_RAW: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
|
||||||
|
|
||||||
|
# 渲染引擎选择:legacy=旧VideoComposeService,unified=新UnifiedRenderService
|
||||||
|
RENDER_ENGINE: str = "legacy"
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Core configuration package."""
|
|
||||||
+11
-280
@@ -1,283 +1,14 @@
|
|||||||
"""阿里云 OSS 存储服务"""
|
"""Backward-compatible re-export from shared storage.
|
||||||
|
|
||||||
import base64
|
All storage logic now lives in ``packages.shared.storage``.
|
||||||
import datetime as dt
|
This module keeps old import paths working so existing code
|
||||||
import hashlib
|
does not need to change.
|
||||||
import hmac
|
"""
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
try:
|
from packages.shared.storage import SharedStorageService as OSSStorageService
|
||||||
import oss2
|
from packages.shared.storage import (
|
||||||
except ImportError: # pragma: no cover - exercised in minimal local/test environments
|
get_shared_storage_service,
|
||||||
oss2 = None
|
get_storage_service,
|
||||||
from app.config import get_settings
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
__all__ = ["OSSStorageService", "get_storage_service", "get_shared_storage_service"]
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
has_key_id = bool(settings.OSS_ACCESS_KEY_ID)
|
|
||||||
has_key_secret = bool(settings.OSS_ACCESS_KEY_SECRET)
|
|
||||||
|
|
||||||
if has_key_id and has_key_secret:
|
|
||||||
if oss2 is not None:
|
|
||||||
try:
|
|
||||||
# P0-2 修复:oss2.Bucket 的 endpoint 必须带 https:// 前缀,
|
|
||||||
# 否则 sign_url 默认生成 HTTP URL。
|
|
||||||
bucket_endpoint = settings.OSS_ENDPOINT
|
|
||||||
if not bucket_endpoint.startswith(("http://", "https://")):
|
|
||||||
bucket_endpoint = f"https://{bucket_endpoint}"
|
|
||||||
auth = oss2.Auth(
|
|
||||||
settings.OSS_ACCESS_KEY_ID,
|
|
||||||
settings.OSS_ACCESS_KEY_SECRET,
|
|
||||||
)
|
|
||||||
self.bucket = oss2.Bucket(
|
|
||||||
auth,
|
|
||||||
bucket_endpoint,
|
|
||||||
settings.OSS_BUCKET_NAME,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"OSS initialized: endpoint=%s bucket=%s",
|
|
||||||
settings.OSS_ENDPOINT,
|
|
||||||
settings.OSS_BUCKET_NAME,
|
|
||||||
)
|
|
||||||
except Exception as error:
|
|
||||||
logger.error("Failed to initialize OSS bucket client: %s", error)
|
|
||||||
else:
|
|
||||||
logger.error("oss2 SDK is not installed — OSS operations will fail")
|
|
||||||
else:
|
|
||||||
missing = []
|
|
||||||
if not has_key_id:
|
|
||||||
missing.append("OSS_ACCESS_KEY_ID")
|
|
||||||
if not has_key_secret:
|
|
||||||
missing.append("OSS_ACCESS_KEY_SECRET")
|
|
||||||
logger.error("OSS credentials not configured — missing: %s", ", ".join(missing))
|
|
||||||
|
|
||||||
self.access_key_id = settings.OSS_ACCESS_KEY_ID
|
|
||||||
self.access_key_secret = settings.OSS_ACCESS_KEY_SECRET
|
|
||||||
self.endpoint = settings.OSS_ENDPOINT
|
|
||||||
|
|
||||||
def diagnose(self) -> None:
|
|
||||||
"""启动诊断:输出 OSS 配置状态,帮助排查预签名 URL 问题。"""
|
|
||||||
key_id_display = (
|
|
||||||
f"{self.access_key_id[:4]}...{self.access_key_id[-4:]}" if len(self.access_key_id) > 8 else "(empty)"
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"[OSS诊断] endpoint=%s bucket_name=%s access_key_id=%s",
|
|
||||||
self.endpoint,
|
|
||||||
self.bucket_name,
|
|
||||||
key_id_display,
|
|
||||||
)
|
|
||||||
if self.bucket is None:
|
|
||||||
logger.error(
|
|
||||||
"[OSS诊断] ❌ bucket=None — 预签名URL不可用!"
|
|
||||||
"原因: OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET 未配置或 oss2 未安装。"
|
|
||||||
"请检查服务器 .env 文件(如 /var/lib/xiaoxia-saas-staging/.env)"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info("[OSS诊断] ✅ bucket 已配置,预签名URL可用")
|
|
||||||
|
|
||||||
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
|
|
||||||
parsed = urlparse(storage_key_or_url)
|
|
||||||
path = parsed.path if parsed.scheme else storage_key_or_url
|
|
||||||
return path.startswith(f"{self.local_url_prefix}/")
|
|
||||||
|
|
||||||
def create_direct_upload_post(
|
|
||||||
self,
|
|
||||||
storage_key: str,
|
|
||||||
content_type: str,
|
|
||||||
max_size_bytes: int,
|
|
||||||
expires_seconds: int,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
"""创建浏览器直传 OSS 的 PostObject 表单。"""
|
|
||||||
if not self.access_key_id or not self.access_key_secret:
|
|
||||||
raise RuntimeError("OSS storage is not configured")
|
|
||||||
normalized_key = self._normalize_storage_key(storage_key)
|
|
||||||
if not normalized_key.startswith("uploads/"):
|
|
||||||
raise ValueError("direct upload key must be under uploads/")
|
|
||||||
|
|
||||||
expiration = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=expires_seconds)).strftime(
|
|
||||||
"%Y-%m-%dT%H:%M:%S.000Z"
|
|
||||||
)
|
|
||||||
policy = {
|
|
||||||
"expiration": expiration,
|
|
||||||
"conditions": [
|
|
||||||
{"bucket": self.bucket_name},
|
|
||||||
{"key": normalized_key},
|
|
||||||
["content-length-range", 1, max_size_bytes],
|
|
||||||
["starts-with", "$Content-Type", content_type.split("/", 1)[0] + "/" if "/" in content_type else ""],
|
|
||||||
],
|
|
||||||
}
|
|
||||||
encoded_policy = base64.b64encode(json.dumps(policy, separators=(",", ":")).encode("utf-8")).decode("ascii")
|
|
||||||
signature = base64.b64encode(
|
|
||||||
hmac.new(self.access_key_secret.encode("utf-8"), encoded_policy.encode("utf-8"), hashlib.sha1).digest()
|
|
||||||
).decode("ascii")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"url": self.public_url,
|
|
||||||
"method": "POST",
|
|
||||||
"storage_key": normalized_key,
|
|
||||||
"expires_at": expiration,
|
|
||||||
"fields": {
|
|
||||||
"key": normalized_key,
|
|
||||||
"OSSAccessKeyId": self.access_key_id,
|
|
||||||
"policy": encoded_policy,
|
|
||||||
"Signature": signature,
|
|
||||||
"success_action_status": "201",
|
|
||||||
"Content-Type": content_type,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def upload_file(
|
|
||||||
self,
|
|
||||||
file_or_path,
|
|
||||||
storage_key: str,
|
|
||||||
content_type: str = "application/octet-stream",
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
上传文件到 OSS
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_or_path: 文件对象或本地文件路径
|
|
||||||
storage_key: 存储键(文件路径)
|
|
||||||
content_type: 内容类型
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
文件公网 URL
|
|
||||||
"""
|
|
||||||
if self.bucket is None:
|
|
||||||
raise RuntimeError("OSS storage is not configured")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 如果是字符串路径,从本地文件上传
|
|
||||||
if isinstance(file_or_path, str):
|
|
||||||
self.bucket.put_object_from_file(storage_key, file_or_path, headers={"Content-Type": content_type})
|
|
||||||
else:
|
|
||||||
# 文件对象
|
|
||||||
file_or_path.seek(0)
|
|
||||||
self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type})
|
|
||||||
|
|
||||||
return f"{self.public_url}/{storage_key}"
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Failed to upload file to OSS: {e}")
|
|
||||||
|
|
||||||
def get_url(self, storage_key: str) -> str:
|
|
||||||
"""获取文件公网 URL"""
|
|
||||||
return f"{self.public_url}/{storage_key}"
|
|
||||||
|
|
||||||
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
|
|
||||||
"""
|
|
||||||
获取文件下载签名 URL(用于私有文件)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
storage_key_or_url: 存储键或完整 URL
|
|
||||||
expires_seconds: 过期时间(秒)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
签名 URL
|
|
||||||
"""
|
|
||||||
if self.bucket is None:
|
|
||||||
if self._is_local_generated_url(storage_key_or_url):
|
|
||||||
return storage_key_or_url
|
|
||||||
logger.warning(
|
|
||||||
"get_download_url: OSS bucket not configured, returning raw URL. " "storage_key_or_url=%s",
|
|
||||||
storage_key_or_url[:200],
|
|
||||||
)
|
|
||||||
return self.get_url(self._normalize_storage_key(storage_key_or_url))
|
|
||||||
|
|
||||||
storage_key = self._normalize_storage_key(storage_key_or_url)
|
|
||||||
try:
|
|
||||||
signed = self.bucket.sign_url("GET", storage_key, expires_seconds)
|
|
||||||
logger.info(
|
|
||||||
"get_download_url: signed URL generated. storage_key=%s url_prefix=%s",
|
|
||||||
storage_key[:80],
|
|
||||||
signed[:60],
|
|
||||||
)
|
|
||||||
return signed
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"get_download_url: sign_url failed, falling back to raw URL. " "storage_key=%s",
|
|
||||||
storage_key[:200],
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
_storage_service = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_storage_service() -> OSSStorageService:
|
|
||||||
"""获取存储服务实例(全局单例)"""
|
|
||||||
global _storage_service
|
|
||||||
if _storage_service is None:
|
|
||||||
_storage_service = OSSStorageService()
|
|
||||||
_storage_service.diagnose()
|
|
||||||
return _storage_service
|
|
||||||
|
|||||||
@@ -50,20 +50,8 @@ from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
|
|||||||
from packages.adapters.sqlalchemy_impl.voice_library_repository import (
|
from packages.adapters.sqlalchemy_impl.voice_library_repository import (
|
||||||
SQLAlchemyVoiceLibraryRepository,
|
SQLAlchemyVoiceLibraryRepository,
|
||||||
)
|
)
|
||||||
from packages.ports.asset_library_repository import AssetLibraryRepository
|
|
||||||
from packages.ports.asset_repository import AssetRepository
|
|
||||||
from packages.ports.classification_job_repository import ClassificationJobRepository
|
|
||||||
from packages.ports.duplication_repository import DuplicationRecordRepository
|
|
||||||
from packages.ports.generated_video_repository import GeneratedVideoRepository
|
|
||||||
from packages.ports.generation_task_repository import GenerationTaskRepository
|
|
||||||
from packages.ports.ingest_job_repository import IngestJobRepository
|
|
||||||
from packages.ports.job_repository import JobRepository
|
|
||||||
from packages.ports.project_repository import ProjectRepository
|
|
||||||
from packages.ports.tag_repository import TagRepository
|
from packages.ports.tag_repository import TagRepository
|
||||||
from packages.ports.title_library_repository import TitleLibraryRepository
|
|
||||||
from packages.ports.user_repository import UserRepository
|
from packages.ports.user_repository import UserRepository
|
||||||
from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository
|
|
||||||
from packages.ports.voice_library_repository import VoiceLibraryRepository
|
|
||||||
|
|
||||||
_engine, _SessionLocal = build_session_factory(settings.DATABASE_URL)
|
_engine, _SessionLocal = build_session_factory(settings.DATABASE_URL)
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ common auth dependencies for backward compatibility.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser
|
from app.auth import AuthenticatedUser
|
||||||
from app.auth import get_current_user as get_authenticated_user
|
from app.auth import get_current_user as get_authenticated_user
|
||||||
from app.dependencies import get_user_repository
|
from app.dependencies import get_user_repository
|
||||||
from fastapi import Depends
|
from fastapi import Depends, HTTPException
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from packages.domain.entities import User
|
from packages.domain.entities import User
|
||||||
from packages.ports.user_repository import UserRepository
|
from packages.ports.user_repository import UserRepository
|
||||||
|
|
||||||
@@ -33,6 +37,10 @@ async def get_current_user_optional(
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
authenticated_user = await get_authenticated_user(credentials, user_repository)
|
authenticated_user = await get_authenticated_user(credentials, user_repository)
|
||||||
except HTTPException:
|
except HTTPException as exc:
|
||||||
|
if exc.status_code >= 500:
|
||||||
|
# 服务端错误不应被静默吞掉,记录日志
|
||||||
|
logger.error("可选认证遇到服务端错误,status=%s", exc.status_code, exc_info=True)
|
||||||
|
# 4xx 认证失败(如 token 无效、用户不存在)属于正常流程,返回 None
|
||||||
return None
|
return None
|
||||||
return authenticated_user.user
|
return authenticated_user.user
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from fastapi import Request, Response
|
from fastapi import Request
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class APIVersionMiddleware(BaseHTTPMiddleware):
|
|||||||
class VersionNotFoundMiddleware(BaseHTTPMiddleware):
|
class VersionNotFoundMiddleware(BaseHTTPMiddleware):
|
||||||
"""处理已下线的 API 版本"""
|
"""处理已下线的 API 版本"""
|
||||||
|
|
||||||
SUNSET_VERSIONS = [] # 已下线的版本列表
|
SUNSET_VERSIONS: list[str] = [] # 已下线的版本列表
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
version = self._extract_version(request.url.path)
|
version = self._extract_version(request.url.path)
|
||||||
|
|||||||
Regular → Executable
+34
-6
@@ -54,17 +54,45 @@ class AssetResponse(BaseModel):
|
|||||||
tag_ids: list[str] = Field(default_factory=list)
|
tag_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
MAX_BATCH_SIZE = 200
|
||||||
|
|
||||||
|
|
||||||
class BatchDeleteRequest(BaseModel):
|
class BatchDeleteRequest(BaseModel):
|
||||||
"""批量删除请求。"""
|
"""批量删除请求(软删除)。"""
|
||||||
|
|
||||||
ids: list[str] = Field(..., min_length=1, max_length=100, description="要删除的素材 ID 列表")
|
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="要删除的素材 ID 列表")
|
||||||
|
|
||||||
|
|
||||||
class BatchDeleteResponse(BaseModel):
|
class BatchOperationResponse(BaseModel):
|
||||||
"""批量删除响应。"""
|
"""批量操作通用响应。"""
|
||||||
|
|
||||||
deleted_count: int = Field(..., ge=0, description="实际删除数量")
|
success_count: int = Field(..., ge=0, description="成功数量")
|
||||||
failed_ids: list[str] = Field(default_factory=list, description="删除失败的 ID 列表")
|
failed_ids: list[str] = Field(default_factory=list, description="失败的 ID 列表")
|
||||||
|
failed_details: dict[str, str] = Field(default_factory=dict, description="失败详情 {asset_id: reason}")
|
||||||
|
|
||||||
|
|
||||||
|
class BatchTagRequest(BaseModel):
|
||||||
|
"""批量打标签请求。"""
|
||||||
|
|
||||||
|
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
|
||||||
|
tag_ids: list[str] = Field(..., min_length=1, max_length=50, description="标签 ID 列表")
|
||||||
|
mode: str = Field(default="add", pattern="^(add|replace)$", description="add=添加合并,replace=全量替换")
|
||||||
|
|
||||||
|
|
||||||
|
class BatchClassifyRequest(BaseModel):
|
||||||
|
"""批量修改分类请求。"""
|
||||||
|
|
||||||
|
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
|
||||||
|
category: str = Field(..., min_length=1, max_length=50, description="内容分类,如 person/scenic/product")
|
||||||
|
|
||||||
|
|
||||||
|
class BatchMarkRequest(BaseModel):
|
||||||
|
"""批量设置智能视图标记请求。"""
|
||||||
|
|
||||||
|
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
|
||||||
|
smart_view: str = Field(
|
||||||
|
..., pattern="^(recommended|caution|high_risk)$", description="智能视图标记:recommended/caution/high_risk"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ListAssetsResponse(BaseModel):
|
class ListAssetsResponse(BaseModel):
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class RecentTaskItem(BaseModel):
|
|
||||||
id: str
|
|
||||||
task_type: str = "generation"
|
|
||||||
status: str
|
|
||||||
current_step: str = ""
|
|
||||||
error_message: str = ""
|
|
||||||
updated_at: datetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionInfo(BaseModel):
|
|
||||||
"""用户订阅信息。"""
|
|
||||||
|
|
||||||
plan: str = "free"
|
|
||||||
is_active: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class DashboardOverviewResponse(BaseModel):
|
|
||||||
"""Dashboard 概览数据。"""
|
|
||||||
|
|
||||||
total_assets: int = 0
|
|
||||||
used_storage_bytes: int = 0
|
|
||||||
total_titles: int = 0
|
|
||||||
total_voices: int = 0
|
|
||||||
total_tasks: int = 0
|
|
||||||
total_products: int = 0
|
|
||||||
subscription: SubscriptionInfo = Field(default_factory=SubscriptionInfo)
|
|
||||||
recent_tasks: list[RecentTaskItem] = Field(default_factory=list)
|
|
||||||
Regular → Executable
+15
@@ -33,6 +33,17 @@ class CreateGenerationTaskRequest(BaseModel):
|
|||||||
asset_select_count: int = Field(
|
asset_select_count: int = Field(
|
||||||
default=0, ge=0, le=100, description="选取数量,0表示全部(仅 random/smart 模式有效)"
|
default=0, ge=0, le=100, description="选取数量,0表示全部(仅 random/smart 模式有效)"
|
||||||
)
|
)
|
||||||
|
# ── 自动重试 ──
|
||||||
|
auto_retry_enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="是否开启失败自动重试,默认关闭",
|
||||||
|
)
|
||||||
|
auto_retry_max: int = Field(
|
||||||
|
default=0,
|
||||||
|
ge=0,
|
||||||
|
le=5,
|
||||||
|
description="最大自动重试次数,0表示不自动重试,最大5次",
|
||||||
|
)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||||
@@ -64,6 +75,10 @@ class GenerationTaskResponse(BaseModel):
|
|||||||
progress: float
|
progress: float
|
||||||
result_count: int
|
result_count: int
|
||||||
error_message: str
|
error_message: str
|
||||||
|
error_info: dict = Field(default_factory=dict)
|
||||||
|
retry_count: int = 0
|
||||||
|
auto_retry_enabled: bool = False
|
||||||
|
auto_retry_max: int = 0
|
||||||
logs: list[dict] = Field(default_factory=list)
|
logs: list[dict] = Field(default_factory=list)
|
||||||
|
|
||||||
@field_validator("logs", mode="before")
|
@field_validator("logs", mode="before")
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
"""Job API schemas — Phase 8 任务 2.10."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class CreateJobRequest(BaseModel):
|
|
||||||
"""创建任务请求体。"""
|
|
||||||
|
|
||||||
project_id: str = Field(..., min_length=1, description="项目 ID")
|
|
||||||
job_type: str = Field(
|
|
||||||
...,
|
|
||||||
description="任务类型: video_compose / render_edit_plan / asset_ingest / classification / voice_extraction / generation",
|
|
||||||
)
|
|
||||||
payload: dict[str, Any] = Field(default_factory=dict, description="任务输入参数")
|
|
||||||
source_id: str = Field(default="", description="关联的业务实体 ID(如 edit_plan_id)")
|
|
||||||
max_retries: int = Field(default=3, ge=0, le=10, description="最大重试次数")
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateProgressRequest(BaseModel):
|
|
||||||
"""更新任务进度请求体。"""
|
|
||||||
|
|
||||||
progress: float = Field(..., ge=0.0, le=100.0, description="进度百分比")
|
|
||||||
current_stage: str = Field(default="", description="当前阶段描述")
|
|
||||||
|
|
||||||
|
|
||||||
class CompleteJobRequest(BaseModel):
|
|
||||||
"""完成任务请求体。"""
|
|
||||||
|
|
||||||
result: dict[str, Any] = Field(default_factory=dict, description="任务结果")
|
|
||||||
|
|
||||||
|
|
||||||
class FailJobRequest(BaseModel):
|
|
||||||
"""标记任务失败请求体。"""
|
|
||||||
|
|
||||||
error_message: str = Field(..., min_length=1, description="错误信息")
|
|
||||||
|
|
||||||
|
|
||||||
class JobResponse(BaseModel):
|
|
||||||
"""任务响应体。"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
project_id: str
|
|
||||||
job_type: str
|
|
||||||
status: str
|
|
||||||
progress: float
|
|
||||||
current_stage: str
|
|
||||||
payload: dict[str, Any]
|
|
||||||
result: dict[str, Any]
|
|
||||||
error_message: str
|
|
||||||
retry_count: int
|
|
||||||
max_retries: int
|
|
||||||
celery_task_id: str
|
|
||||||
source_id: str
|
|
||||||
created_by_user_id: str
|
|
||||||
is_retryable: bool
|
|
||||||
started_at: Optional[datetime] = None
|
|
||||||
completed_at: Optional[datetime] = None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
class ListJobsResponse(BaseModel):
|
|
||||||
"""任务列表响应体。"""
|
|
||||||
|
|
||||||
items: list[JobResponse]
|
|
||||||
total: int
|
|
||||||
|
|
||||||
|
|
||||||
class JobStatisticsResponse(BaseModel):
|
|
||||||
"""任务统计响应体。"""
|
|
||||||
|
|
||||||
project_id: str
|
|
||||||
total: int
|
|
||||||
pending: int
|
|
||||||
running: int
|
|
||||||
success: int
|
|
||||||
failed: int
|
|
||||||
|
|
||||||
|
|
||||||
def job_to_response(job) -> JobResponse:
|
|
||||||
"""将 Job 领域对象转换为 API 响应。"""
|
|
||||||
return JobResponse(
|
|
||||||
id=job.id,
|
|
||||||
project_id=job.project_id,
|
|
||||||
job_type=job.job_type.value if hasattr(job.job_type, "value") else str(job.job_type),
|
|
||||||
status=job.status.value if hasattr(job.status, "value") else str(job.status),
|
|
||||||
progress=job.progress,
|
|
||||||
current_stage=job.current_stage,
|
|
||||||
payload=job.payload,
|
|
||||||
result=job.result,
|
|
||||||
error_message=job.error_message,
|
|
||||||
retry_count=job.retry_count,
|
|
||||||
max_retries=job.max_retries,
|
|
||||||
celery_task_id=job.celery_task_id,
|
|
||||||
source_id=job.source_id,
|
|
||||||
created_by_user_id=job.created_by_user_id,
|
|
||||||
is_retryable=job.is_retryable,
|
|
||||||
started_at=job.started_at,
|
|
||||||
completed_at=job.completed_at,
|
|
||||||
created_at=job.created_at,
|
|
||||||
updated_at=job.updated_at,
|
|
||||||
)
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"""Recipe API schemas."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
# ── Response ──
|
|
||||||
|
|
||||||
|
|
||||||
class RecipeItemResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
recipe_id: str
|
|
||||||
item_type: str
|
|
||||||
item_id: str
|
|
||||||
position: int
|
|
||||||
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
populate_by_name = True
|
|
||||||
|
|
||||||
|
|
||||||
class RecipeResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
user_id: str
|
|
||||||
name: str
|
|
||||||
description: str = ""
|
|
||||||
template_id: str = ""
|
|
||||||
generation_params: Dict[str, Any] = Field(default_factory=dict)
|
|
||||||
items: List[RecipeItemResponse] = Field(default_factory=list)
|
|
||||||
is_active: bool = True
|
|
||||||
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
populate_by_name = True
|
|
||||||
|
|
||||||
|
|
||||||
class ListRecipesResponse(BaseModel):
|
|
||||||
items: List[RecipeResponse]
|
|
||||||
total: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class UseRecipeResponse(BaseModel):
|
|
||||||
recipe: RecipeResponse
|
|
||||||
warnings: List[Dict[str, Any]] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Request ──
|
|
||||||
|
|
||||||
|
|
||||||
class RecipeItemRequest(BaseModel):
|
|
||||||
item_type: str
|
|
||||||
item_id: str
|
|
||||||
position: int = 0
|
|
||||||
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
populate_by_name = True
|
|
||||||
|
|
||||||
|
|
||||||
class CreateRecipeRequest(BaseModel):
|
|
||||||
name: str
|
|
||||||
description: str = ""
|
|
||||||
template_id: str = ""
|
|
||||||
generation_params: Dict[str, Any] = Field(default_factory=dict)
|
|
||||||
items: List[RecipeItemRequest] = Field(default_factory=list)
|
|
||||||
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
populate_by_name = True
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateRecipeRequest(BaseModel):
|
|
||||||
name: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
template_id: Optional[str] = None
|
|
||||||
generation_params: Optional[Dict[str, Any]] = None
|
|
||||||
items: Optional[List[RecipeItemRequest]] = None
|
|
||||||
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata")
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
populate_by_name = True
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+6
@@ -11,8 +11,10 @@ class ProjectTaskResponse(BaseModel):
|
|||||||
progress: float
|
progress: float
|
||||||
current_step: str
|
current_step: str
|
||||||
error_message: str = ""
|
error_message: str = ""
|
||||||
|
error_info: dict = Field(default_factory=dict)
|
||||||
user_message: str = ""
|
user_message: str = ""
|
||||||
retryable: bool = False
|
retryable: bool = False
|
||||||
|
retry_count: int = 0
|
||||||
source_id: str = ""
|
source_id: str = ""
|
||||||
template_id: str = ""
|
template_id: str = ""
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
@@ -21,6 +23,7 @@ class ProjectTaskResponse(BaseModel):
|
|||||||
|
|
||||||
class ListProjectTasksResponse(BaseModel):
|
class ListProjectTasksResponse(BaseModel):
|
||||||
items: list[ProjectTaskResponse] = Field(default_factory=list)
|
items: list[ProjectTaskResponse] = Field(default_factory=list)
|
||||||
|
total: int = 0
|
||||||
|
|
||||||
|
|
||||||
class UserTaskResponse(BaseModel):
|
class UserTaskResponse(BaseModel):
|
||||||
@@ -34,8 +37,10 @@ class UserTaskResponse(BaseModel):
|
|||||||
progress: float
|
progress: float
|
||||||
current_step: str
|
current_step: str
|
||||||
error_message: str = ""
|
error_message: str = ""
|
||||||
|
error_info: dict = Field(default_factory=dict)
|
||||||
user_message: str = ""
|
user_message: str = ""
|
||||||
retryable: bool = False
|
retryable: bool = False
|
||||||
|
retry_count: int = 0
|
||||||
source_id: str = ""
|
source_id: str = ""
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
updated_at: datetime | None = None
|
updated_at: datetime | None = None
|
||||||
@@ -45,3 +50,4 @@ class ListTasksResponse(BaseModel):
|
|||||||
"""用户级任务列表响应(GET /api/v1/tasks)。"""
|
"""用户级任务列表响应(GET /api/v1/tasks)。"""
|
||||||
|
|
||||||
items: list[UserTaskResponse] = Field(default_factory=list)
|
items: list[UserTaskResponse] = Field(default_factory=list)
|
||||||
|
total: int = 0
|
||||||
|
|||||||
Regular → Executable
+23
@@ -45,6 +45,7 @@ class TemplateResponse(BaseModel):
|
|||||||
segments: List[SegmentResponse] = Field(default_factory=list)
|
segments: List[SegmentResponse] = Field(default_factory=list)
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
is_favorite: bool = False
|
is_favorite: bool = False
|
||||||
|
usage_count: int = 0
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -120,3 +121,25 @@ class CreateCategoryRequest(BaseModel):
|
|||||||
|
|
||||||
class ListCategoriesResponse(BaseModel):
|
class ListCategoriesResponse(BaseModel):
|
||||||
items: List[CategoryResponse]
|
items: List[CategoryResponse]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Copy Template ──
|
||||||
|
|
||||||
|
|
||||||
|
class CopyTemplateRequest(BaseModel):
|
||||||
|
new_name: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tags ──
|
||||||
|
|
||||||
|
|
||||||
|
class ListTagsResponse(BaseModel):
|
||||||
|
items: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Usage Stats ──
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateUsageResponse(BaseModel):
|
||||||
|
template_id: str
|
||||||
|
usage_count: int
|
||||||
|
|||||||
Executable
+45
@@ -0,0 +1,45 @@
|
|||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
VideoReviewStatus = Literal["pending_review", "approved", "rejected"]
|
||||||
|
|
||||||
|
|
||||||
|
class VideoItemResponse(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
|
||||||
|
generated_at: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ListVideosResponse(BaseModel):
|
||||||
|
items: list[VideoItemResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateVideoReviewRequest(BaseModel):
|
||||||
|
review_status: VideoReviewStatus
|
||||||
|
|
||||||
|
|
||||||
|
class BatchDownloadRequest(BaseModel):
|
||||||
|
video_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class BatchDownloadResponse(BaseModel):
|
||||||
|
job_id: str
|
||||||
|
status: str = "pending"
|
||||||
|
download_url: str | None = None
|
||||||
@@ -24,7 +24,7 @@ from packages.adapters.sqlalchemy_impl import (
|
|||||||
)
|
)
|
||||||
from packages.domain.asset import AssetType
|
from packages.domain.asset import AssetType
|
||||||
from packages.domain.classification import AssetClassification
|
from packages.domain.classification import AssetClassification
|
||||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
from packages.domain.edit_plan_clip import EditPlanClip
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+19
-1
@@ -18,7 +18,6 @@ from packages.adapters.sqlalchemy_impl import (
|
|||||||
)
|
)
|
||||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||||
from packages.domain.generation_task import GenerationTaskStatus
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -282,6 +281,8 @@ class EditPlanService:
|
|||||||
start_time: float = 0.0,
|
start_time: float = 0.0,
|
||||||
duration: float = 0.0,
|
duration: float = 0.0,
|
||||||
transition_effect: str = "cut",
|
transition_effect: str = "cut",
|
||||||
|
transition_duration: float = 0.0,
|
||||||
|
playback_speed: float = 1.0,
|
||||||
config: Optional[dict[str, Any]] = None,
|
config: Optional[dict[str, Any]] = None,
|
||||||
) -> EditPlanClip:
|
) -> EditPlanClip:
|
||||||
"""创建片段
|
"""创建片段
|
||||||
@@ -302,6 +303,8 @@ class EditPlanService:
|
|||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
transition_effect=transition_effect,
|
transition_effect=transition_effect,
|
||||||
|
transition_duration=transition_duration,
|
||||||
|
playback_speed=playback_speed,
|
||||||
config=config,
|
config=config,
|
||||||
)
|
)
|
||||||
created = self._clip_repo.create(clip)
|
created = self._clip_repo.create(clip)
|
||||||
@@ -325,6 +328,8 @@ class EditPlanService:
|
|||||||
start_time: Optional[float] = None,
|
start_time: Optional[float] = None,
|
||||||
duration: Optional[float] = None,
|
duration: Optional[float] = None,
|
||||||
transition_effect: Optional[str] = None,
|
transition_effect: Optional[str] = None,
|
||||||
|
transition_duration: Optional[float] = None,
|
||||||
|
playback_speed: Optional[float] = None,
|
||||||
config: Optional[dict[str, Any]] = None,
|
config: Optional[dict[str, Any]] = None,
|
||||||
) -> EditPlanClip:
|
) -> EditPlanClip:
|
||||||
"""更新片段
|
"""更新片段
|
||||||
@@ -334,6 +339,15 @@ class EditPlanService:
|
|||||||
"""
|
"""
|
||||||
existing = self.get_clip_or_raise(clip_id)
|
existing = self.get_clip_or_raise(clip_id)
|
||||||
|
|
||||||
|
# 速度边界钳制
|
||||||
|
if playback_speed is not None:
|
||||||
|
if playback_speed <= 0:
|
||||||
|
playback_speed = 1.0
|
||||||
|
elif playback_speed < 0.25:
|
||||||
|
playback_speed = 0.25
|
||||||
|
elif playback_speed > 4.0:
|
||||||
|
playback_speed = 4.0
|
||||||
|
|
||||||
updated = EditPlanClip(
|
updated = EditPlanClip(
|
||||||
id=existing.id,
|
id=existing.id,
|
||||||
plan_id=existing.plan_id,
|
plan_id=existing.plan_id,
|
||||||
@@ -347,6 +361,10 @@ class EditPlanService:
|
|||||||
transition_effect=(
|
transition_effect=(
|
||||||
transition_effect.strip() if transition_effect is not None else existing.transition_effect
|
transition_effect.strip() if transition_effect is not None else existing.transition_effect
|
||||||
),
|
),
|
||||||
|
transition_duration=(
|
||||||
|
transition_duration if transition_duration is not None else existing.transition_duration
|
||||||
|
),
|
||||||
|
playback_speed=playback_speed if playback_speed is not None else existing.playback_speed,
|
||||||
status=existing.status,
|
status=existing.status,
|
||||||
config=config if config is not None else existing.config,
|
config=config if config is not None else existing.config,
|
||||||
created_at=existing.created_at,
|
created_at=existing.created_at,
|
||||||
|
|||||||
Executable → Regular
-2
@@ -13,8 +13,6 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from packages.application.jobs import (
|
from packages.application.jobs import (
|
||||||
CancelJobUseCase,
|
CancelJobUseCase,
|
||||||
CompleteJobCommand,
|
CompleteJobCommand,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ FFmpeg 视频合成编排服务:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import (
|
|||||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||||||
SQLAlchemyEditPlanRepository,
|
SQLAlchemyEditPlanRepository,
|
||||||
)
|
)
|
||||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
from packages.domain.edit_plan import EditPlanStatus
|
||||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||||
from packages.domain.template_clip_config import TransitionEffect
|
from packages.domain.template_clip_config import TransitionEffect
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,10 @@ async function createProject(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const resp = await request.post(`${apiBase}/projects`, {
|
const resp = await request.post(`${apiBase}/projects`, {
|
||||||
headers,
|
headers,
|
||||||
data: { name: `Assets Test Proj ${suffix}`, description: "E2E assets test" },
|
data: {
|
||||||
|
name: `Assets Test Proj ${suffix}`,
|
||||||
|
description: "E2E assets test",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
|
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
@@ -178,20 +181,30 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
|
|
||||||
test("素材库列表页面加载", async ({ page, request }) => {
|
test("素材库列表页面加载", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page);
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-load");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-load",
|
headers,
|
||||||
|
Date.now().toString(),
|
||||||
);
|
);
|
||||||
const projectId = await createProject(request, headers, Date.now().toString());
|
|
||||||
await createLibrary(request, headers, projectId, "默认视频库", "video");
|
await createLibrary(request, headers, projectId, "默认视频库", "video");
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets");
|
await page.goto("/app/assets");
|
||||||
|
|
||||||
// 页面布局容器
|
// 页面布局容器
|
||||||
await expect(page.locator(".xx-assets-page")).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator(".xx-assets-page")).toBeVisible({
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
|
||||||
// 左侧素材库列表
|
// 左侧素材库列表
|
||||||
await expect(page.locator(".xx-asset-library-list")).toBeVisible();
|
await expect(page.locator(".xx-asset-library-list")).toBeVisible();
|
||||||
@@ -211,23 +224,33 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
|
|
||||||
test("创建新素材库 - 通过 UI", async ({ page, request }) => {
|
test("创建新素材库 - 通过 UI", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page);
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-create");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-create",
|
headers,
|
||||||
|
Date.now().toString(),
|
||||||
);
|
);
|
||||||
const projectId = await createProject(request, headers, Date.now().toString());
|
|
||||||
await createLibrary(request, headers, projectId, "初始库", "video");
|
await createLibrary(request, headers, projectId, "初始库", "video");
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets");
|
await page.goto("/app/assets");
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
|
||||||
// 点击新建素材库
|
// 点击新建素材库
|
||||||
await page.locator(".xx-asset-library-add").click();
|
await page.locator(".xx-asset-library-add").click();
|
||||||
|
|
||||||
// 弹窗出现
|
// 弹窗出现
|
||||||
const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" });
|
const modal = page
|
||||||
|
.locator(".ant-modal-content")
|
||||||
|
.filter({ hasText: "新建素材库" });
|
||||||
await expect(modal).toBeVisible();
|
await expect(modal).toBeVisible();
|
||||||
|
|
||||||
// 填写表单
|
// 填写表单
|
||||||
@@ -259,11 +282,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
|
|
||||||
test("切换不同素材库", async ({ page, request }) => {
|
test("切换不同素材库", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page);
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-switch");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-switch",
|
headers,
|
||||||
|
Date.now().toString(),
|
||||||
);
|
);
|
||||||
const projectId = await createProject(request, headers, Date.now().toString());
|
|
||||||
|
|
||||||
const videoLibName = "视频素材库 A";
|
const videoLibName = "视频素材库 A";
|
||||||
const imageLibName = "图片素材库 B";
|
const imageLibName = "图片素材库 B";
|
||||||
@@ -292,10 +317,16 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
"demo_video.mp4",
|
"demo_video.mp4",
|
||||||
);
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets");
|
await page.goto("/app/assets");
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
|
||||||
// 点击视频库,应显示素材
|
// 点击视频库,应显示素材
|
||||||
const videoLibItem = page
|
const videoLibItem = page
|
||||||
@@ -305,7 +336,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
await expect(videoLibItem).toHaveClass(/active/);
|
await expect(videoLibItem).toHaveClass(/active/);
|
||||||
|
|
||||||
// 验证视频素材出现
|
// 验证视频素材出现
|
||||||
await expect(page.getByText("demo_video.mp4")).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText("demo_video.mp4")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
// 点击图片库,应切换且不显示视频
|
// 点击图片库,应切换且不显示视频
|
||||||
const imageLibItem = page
|
const imageLibItem = page
|
||||||
@@ -315,18 +348,22 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
await expect(imageLibItem).toHaveClass(/active/);
|
await expect(imageLibItem).toHaveClass(/active/);
|
||||||
|
|
||||||
// 空状态或图片库内容
|
// 空状态或图片库内容
|
||||||
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { timeout: 5_000 });
|
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, {
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── 素材搜索 ──────────────────────────────────────
|
// ─── 素材搜索 ──────────────────────────────────────
|
||||||
|
|
||||||
test("素材搜索功能", async ({ page, request }) => {
|
test("素材搜索功能", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page);
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-search");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-search",
|
headers,
|
||||||
|
Date.now().toString(),
|
||||||
);
|
);
|
||||||
const projectId = await createProject(request, headers, Date.now().toString());
|
|
||||||
const libraryId = await createLibrary(
|
const libraryId = await createLibrary(
|
||||||
request,
|
request,
|
||||||
headers,
|
headers,
|
||||||
@@ -336,13 +373,33 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 创建两个不同名称的素材
|
// 创建两个不同名称的素材
|
||||||
await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4");
|
await createAsset(
|
||||||
await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4");
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
libraryId,
|
||||||
|
userId,
|
||||||
|
"apple_clip.mp4",
|
||||||
|
);
|
||||||
|
await createAsset(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
libraryId,
|
||||||
|
userId,
|
||||||
|
"banana_clip.mp4",
|
||||||
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets");
|
await page.goto("/app/assets");
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
|
||||||
// 确保在测试库中
|
// 确保在测试库中
|
||||||
const libItem = page
|
const libItem = page
|
||||||
@@ -351,7 +408,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
await libItem.click({ force: true });
|
await libItem.click({ force: true });
|
||||||
|
|
||||||
// 两个素材都应可见
|
// 两个素材都应可见
|
||||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||||
|
|
||||||
// 搜索 apple,只显示 apple
|
// 搜索 apple,只显示 apple
|
||||||
@@ -361,7 +420,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
|
|
||||||
// 清空搜索,两个都显示
|
// 清空搜索,两个都显示
|
||||||
await page.getByPlaceholder("搜索素材名称...").fill("");
|
await page.getByPlaceholder("搜索素材名称...").fill("");
|
||||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 5_000 });
|
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -369,11 +430,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
|
|
||||||
test("素材类型筛选", async ({ page, request }) => {
|
test("素材类型筛选", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page);
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-filter");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-filter",
|
headers,
|
||||||
|
Date.now().toString(),
|
||||||
);
|
);
|
||||||
const projectId = await createProject(request, headers, Date.now().toString());
|
|
||||||
const libraryId = await createLibrary(
|
const libraryId = await createLibrary(
|
||||||
request,
|
request,
|
||||||
headers,
|
headers,
|
||||||
@@ -383,12 +446,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 创建视频素材
|
// 创建视频素材
|
||||||
await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4");
|
await createAsset(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
libraryId,
|
||||||
|
userId,
|
||||||
|
"video_clip.mp4",
|
||||||
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets");
|
await page.goto("/app/assets");
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
|
||||||
const libItem = page
|
const libItem = page
|
||||||
.locator(".xx-asset-library-item")
|
.locator(".xx-asset-library-item")
|
||||||
@@ -396,7 +472,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
await libItem.click({ force: true });
|
await libItem.click({ force: true });
|
||||||
|
|
||||||
// 素材应可见
|
// 素材应可见
|
||||||
await expect(page.getByText("video_clip.mp4")).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText("video_clip.mp4")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
// 筛选类型下拉存在
|
// 筛选类型下拉存在
|
||||||
const filterSelect = page.locator(".xx-assets-filters-left select").first();
|
const filterSelect = page.locator(".xx-assets-filters-left select").first();
|
||||||
@@ -407,11 +485,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
|
|
||||||
test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
|
test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page);
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-detail");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-detail",
|
headers,
|
||||||
|
Date.now().toString(),
|
||||||
);
|
);
|
||||||
const projectId = await createProject(request, headers, Date.now().toString());
|
|
||||||
const libraryId = await createLibrary(
|
const libraryId = await createLibrary(
|
||||||
request,
|
request,
|
||||||
headers,
|
headers,
|
||||||
@@ -419,12 +499,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
"详情测试库",
|
"详情测试库",
|
||||||
"video",
|
"video",
|
||||||
);
|
);
|
||||||
await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4");
|
await createAsset(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
libraryId,
|
||||||
|
userId,
|
||||||
|
"play_test.mp4",
|
||||||
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets");
|
await page.goto("/app/assets");
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
|
||||||
const libItem = page
|
const libItem = page
|
||||||
.locator(".xx-asset-library-item")
|
.locator(".xx-asset-library-item")
|
||||||
@@ -441,7 +534,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
await assetCard.locator(".xx-asset-play").click({ force: true });
|
await assetCard.locator(".xx-asset-play").click({ force: true });
|
||||||
|
|
||||||
// 播放弹窗出现
|
// 播放弹窗出现
|
||||||
const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" });
|
const modal = page
|
||||||
|
.locator(".ant-modal-content")
|
||||||
|
.filter({ hasText: "播放" });
|
||||||
await expect(modal).toBeVisible();
|
await expect(modal).toBeVisible();
|
||||||
|
|
||||||
// 关闭弹窗
|
// 关闭弹窗
|
||||||
@@ -453,11 +548,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
|
|
||||||
test("删除素材 - 带确认对话框", async ({ page, request }) => {
|
test("删除素材 - 带确认对话框", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page);
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-delete");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-delete",
|
headers,
|
||||||
|
Date.now().toString(),
|
||||||
);
|
);
|
||||||
const projectId = await createProject(request, headers, Date.now().toString());
|
|
||||||
const libraryId = await createLibrary(
|
const libraryId = await createLibrary(
|
||||||
request,
|
request,
|
||||||
headers,
|
headers,
|
||||||
@@ -465,12 +562,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
"删除测试库",
|
"删除测试库",
|
||||||
"video",
|
"video",
|
||||||
);
|
);
|
||||||
await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4");
|
await createAsset(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
libraryId,
|
||||||
|
userId,
|
||||||
|
"to_delete.mp4",
|
||||||
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets");
|
await page.goto("/app/assets");
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
|
||||||
const libItem = page
|
const libItem = page
|
||||||
.locator(".xx-asset-library-item")
|
.locator(".xx-asset-library-item")
|
||||||
@@ -491,14 +601,15 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
await deleteBtn.click({ force: true });
|
await deleteBtn.click({ force: true });
|
||||||
|
|
||||||
// 确认对话框出现
|
// 确认对话框出现
|
||||||
const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" });
|
const confirmModal = page
|
||||||
|
.locator(".ant-popover")
|
||||||
|
.filter({ hasText: "确认删除" });
|
||||||
await expect(confirmModal).toBeVisible();
|
await expect(confirmModal).toBeVisible();
|
||||||
|
|
||||||
// 监听删除请求
|
// 监听删除请求
|
||||||
const deletePromise = page.waitForResponse(
|
const deletePromise = page.waitForResponse(
|
||||||
(resp) =>
|
(resp) =>
|
||||||
resp.url().includes("/assets/") &&
|
resp.url().includes("/assets/") && resp.request().method() === "DELETE",
|
||||||
resp.request().method() === "DELETE",
|
|
||||||
{ timeout: 10_000 },
|
{ timeout: 10_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -518,11 +629,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
|
|
||||||
test("批量删除素材", async ({ page, request }) => {
|
test("批量删除素材", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page);
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-batch");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-batch",
|
headers,
|
||||||
|
Date.now().toString(),
|
||||||
);
|
);
|
||||||
const projectId = await createProject(request, headers, Date.now().toString());
|
|
||||||
const libraryId = await createLibrary(
|
const libraryId = await createLibrary(
|
||||||
request,
|
request,
|
||||||
headers,
|
headers,
|
||||||
@@ -532,14 +645,41 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 创建多个素材
|
// 创建多个素材
|
||||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4");
|
await createAsset(
|
||||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4");
|
request,
|
||||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4");
|
headers,
|
||||||
|
projectId,
|
||||||
|
libraryId,
|
||||||
|
userId,
|
||||||
|
"batch_1.mp4",
|
||||||
|
);
|
||||||
|
await createAsset(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
libraryId,
|
||||||
|
userId,
|
||||||
|
"batch_2.mp4",
|
||||||
|
);
|
||||||
|
await createAsset(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
libraryId,
|
||||||
|
userId,
|
||||||
|
"batch_3.mp4",
|
||||||
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets");
|
await page.goto("/app/assets");
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
|
||||||
const libItem = page
|
const libItem = page
|
||||||
.locator(".xx-asset-library-item")
|
.locator(".xx-asset-library-item")
|
||||||
@@ -547,7 +687,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
await libItem.click({ force: true });
|
await libItem.click({ force: true });
|
||||||
|
|
||||||
// 所有素材应可见
|
// 所有素材应可见
|
||||||
await expect(page.getByText("batch_1.mp4")).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText("batch_1.mp4")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
await expect(page.getByText("batch_2.mp4")).toBeVisible();
|
await expect(page.getByText("batch_2.mp4")).toBeVisible();
|
||||||
await expect(page.getByText("batch_3.mp4")).toBeVisible();
|
await expect(page.getByText("batch_3.mp4")).toBeVisible();
|
||||||
|
|
||||||
@@ -567,7 +709,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
await batchDeleteBtn.click();
|
await batchDeleteBtn.click();
|
||||||
|
|
||||||
// 确认对话框
|
// 确认对话框
|
||||||
const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" });
|
const confirmPop = page
|
||||||
|
.locator(".ant-popover")
|
||||||
|
.filter({ hasText: "确定删除" });
|
||||||
await expect(confirmPop).toBeVisible();
|
await expect(confirmPop).toBeVisible();
|
||||||
|
|
||||||
// 确认删除
|
// 确认删除
|
||||||
@@ -595,17 +739,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
|
|
||||||
test("空素材库展示空状态", async ({ page, request }) => {
|
test("空素材库展示空状态", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page);
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-empty");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-empty",
|
headers,
|
||||||
|
Date.now().toString(),
|
||||||
);
|
);
|
||||||
const projectId = await createProject(request, headers, Date.now().toString());
|
|
||||||
await createLibrary(request, headers, projectId, "空素材库", "video");
|
await createLibrary(request, headers, projectId, "空素材库", "video");
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets");
|
await page.goto("/app/assets");
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
|
||||||
const libItem = page
|
const libItem = page
|
||||||
.locator(".xx-asset-library-item")
|
.locator(".xx-asset-library-item")
|
||||||
@@ -613,7 +765,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
await libItem.click({ force: true });
|
await libItem.click({ force: true });
|
||||||
|
|
||||||
// 空状态应显示
|
// 空状态应显示
|
||||||
await expect(page.locator(".xx-assets-empty")).toBeVisible({ timeout: 10_000 });
|
await expect(page.locator(".xx-assets-empty")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible();
|
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -251,6 +251,9 @@ test.describe("Core generation flow", () => {
|
|||||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错
|
||||||
|
await page.unrouteAll({ behavior: "ignoreErrors" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("generation task API creates and lists tasks", async ({ request }) => {
|
test("generation task API creates and lists tasks", async ({ request }) => {
|
||||||
|
|||||||
@@ -180,9 +180,11 @@ test.describe("Core media upload flow", () => {
|
|||||||
await expect(page.locator(".xx-assets-content")).toBeVisible({
|
await expect(page.locator(".xx-assets-content")).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
});
|
});
|
||||||
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible({
|
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible(
|
||||||
timeout: 20_000,
|
{
|
||||||
});
|
timeout: 20_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Verify asset card shows status
|
// Verify asset card shows status
|
||||||
const assetCard = page
|
const assetCard = page
|
||||||
|
|||||||
@@ -121,7 +121,11 @@ test.describe("去重流程", () => {
|
|||||||
"dup-load",
|
"dup-load",
|
||||||
);
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/duplication");
|
await page.goto("/app/duplication");
|
||||||
|
|
||||||
@@ -146,7 +150,11 @@ test.describe("去重流程", () => {
|
|||||||
"dup-upload-zone",
|
"dup-upload-zone",
|
||||||
);
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/duplication");
|
await page.goto("/app/duplication");
|
||||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||||
@@ -156,12 +164,12 @@ test.describe("去重流程", () => {
|
|||||||
await expect(uploadZone).toBeVisible();
|
await expect(uploadZone).toBeVisible();
|
||||||
|
|
||||||
// 上传图标和文字
|
// 上传图标和文字
|
||||||
await expect(uploadZone.getByText("点击或拖拽视频文件到此区域")).toBeVisible();
|
await expect(
|
||||||
|
uploadZone.getByText("点击或拖拽视频文件到此区域"),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// 格式提示
|
// 格式提示
|
||||||
await expect(
|
await expect(uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/)).toBeVisible();
|
||||||
uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// 格式标签
|
// 格式标签
|
||||||
await expect(page.locator(".dup-upload-formats")).toBeVisible();
|
await expect(page.locator(".dup-upload-formats")).toBeVisible();
|
||||||
@@ -184,7 +192,11 @@ test.describe("去重流程", () => {
|
|||||||
"dup-info",
|
"dup-info",
|
||||||
);
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/duplication");
|
await page.goto("/app/duplication");
|
||||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||||
@@ -215,7 +227,11 @@ test.describe("去重流程", () => {
|
|||||||
"dup-list",
|
"dup-list",
|
||||||
);
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/duplication/results");
|
await page.goto("/app/duplication/results");
|
||||||
|
|
||||||
@@ -239,7 +255,11 @@ test.describe("去重流程", () => {
|
|||||||
"dup-list-empty",
|
"dup-list-empty",
|
||||||
);
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/duplication/results");
|
await page.goto("/app/duplication/results");
|
||||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||||
@@ -257,7 +277,11 @@ test.describe("去重流程", () => {
|
|||||||
"dup-filter",
|
"dup-filter",
|
||||||
);
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/duplication/results");
|
await page.goto("/app/duplication/results");
|
||||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||||
@@ -287,7 +311,11 @@ test.describe("去重流程", () => {
|
|||||||
"dup-nav",
|
"dup-nav",
|
||||||
);
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/duplication/results");
|
await page.goto("/app/duplication/results");
|
||||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||||
@@ -306,10 +334,8 @@ test.describe("去重流程", () => {
|
|||||||
request,
|
request,
|
||||||
}) => {
|
}) => {
|
||||||
await routeBrowserApiToTestApi(page);
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
request,
|
await createAuthedUser(request, "dup-detail");
|
||||||
"dup-detail",
|
|
||||||
);
|
|
||||||
|
|
||||||
// 先上传一个文件进行查重,获取 record id
|
// 先上传一个文件进行查重,获取 record id
|
||||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||||
@@ -335,7 +361,11 @@ test.describe("去重流程", () => {
|
|||||||
const recordId = uploadData.id;
|
const recordId = uploadData.id;
|
||||||
expect(recordId, "应返回查重记录 ID").toBeTruthy();
|
expect(recordId, "应返回查重记录 ID").toBeTruthy();
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
// 访问详情页
|
// 访问详情页
|
||||||
await page.goto(`/app/duplication/${recordId}`);
|
await page.goto(`/app/duplication/${recordId}`);
|
||||||
@@ -353,10 +383,8 @@ test.describe("去重流程", () => {
|
|||||||
|
|
||||||
test("去重记录删除 - API 验证", async ({ page, request }) => {
|
test("去重记录删除 - API 验证", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page);
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
request,
|
await createAuthedUser(request, "dup-delete");
|
||||||
"dup-delete",
|
|
||||||
);
|
|
||||||
|
|
||||||
// 创建查重记录
|
// 创建查重记录
|
||||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||||
@@ -419,10 +447,8 @@ test.describe("去重流程", () => {
|
|||||||
|
|
||||||
test("去重记录删除 - UI 验证", async ({ page, request }) => {
|
test("去重记录删除 - UI 验证", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page);
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
request,
|
await createAuthedUser(request, "dup-delete-ui");
|
||||||
"dup-delete-ui",
|
|
||||||
);
|
|
||||||
|
|
||||||
// 创建查重记录
|
// 创建查重记录
|
||||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||||
@@ -443,14 +469,20 @@ test.describe("去重流程", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/duplication/results");
|
await page.goto("/app/duplication/results");
|
||||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
// 记录卡片应存在
|
// 记录卡片应存在
|
||||||
const resultCard = page.locator(".dup-result-card").first();
|
const resultCard = page.locator(".dup-result-card").first();
|
||||||
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
|
const cardVisible = await resultCard
|
||||||
|
.isVisible({ timeout: 10_000 })
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
if (cardVisible) {
|
if (cardVisible) {
|
||||||
// 删除按钮存在
|
// 删除按钮存在
|
||||||
@@ -467,12 +499,14 @@ test.describe("去重流程", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 监听删除请求
|
// 监听删除请求
|
||||||
const deletePromise = page.waitForResponse(
|
const deletePromise = page
|
||||||
(resp) =>
|
.waitForResponse(
|
||||||
resp.url().includes("/duplication/records/") &&
|
(resp) =>
|
||||||
resp.request().method() === "DELETE",
|
resp.url().includes("/duplication/records/") &&
|
||||||
{ timeout: 10_000 },
|
resp.request().method() === "DELETE",
|
||||||
).catch(() => null);
|
{ timeout: 10_000 },
|
||||||
|
)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
await deleteBtn.click();
|
await deleteBtn.click();
|
||||||
|
|
||||||
@@ -487,10 +521,8 @@ test.describe("去重流程", () => {
|
|||||||
|
|
||||||
test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => {
|
test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page);
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
request,
|
await createAuthedUser(request, "dup-retry");
|
||||||
"dup-retry",
|
|
||||||
);
|
|
||||||
|
|
||||||
// 创建查重记录
|
// 创建查重记录
|
||||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||||
@@ -511,14 +543,20 @@ test.describe("去重流程", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/duplication/results");
|
await page.goto("/app/duplication/results");
|
||||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
// 记录列表中至少有一条记录
|
// 记录列表中至少有一条记录
|
||||||
const resultCard = page.locator(".dup-result-card").first();
|
const resultCard = page.locator(".dup-result-card").first();
|
||||||
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
|
const cardVisible = await resultCard
|
||||||
|
.isVisible({ timeout: 10_000 })
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
if (cardVisible) {
|
if (cardVisible) {
|
||||||
// 验证记录卡片基本结构
|
// 验证记录卡片基本结构
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
*
|
*
|
||||||
* 每个测试独立,先注册登录获取 auth token。
|
* 每个测试独立,先注册登录获取 auth token。
|
||||||
*/
|
*/
|
||||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
import {
|
||||||
|
expect,
|
||||||
|
test,
|
||||||
|
type APIRequestContext,
|
||||||
|
type Page,
|
||||||
|
} from "@playwright/test";
|
||||||
|
|
||||||
const PASSWORD = "Test123456!";
|
const PASSWORD = "Test123456!";
|
||||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||||
@@ -258,7 +263,12 @@ test.describe("剪辑计划 - API 操作", () => {
|
|||||||
mode: "pip",
|
mode: "pip",
|
||||||
estimated_duration: 30,
|
estimated_duration: 30,
|
||||||
segments: [
|
segments: [
|
||||||
{ segment_order: 1, duration_min: 5, duration_max: 10, material_type: "video" },
|
{
|
||||||
|
segment_order: 1,
|
||||||
|
duration_min: 5,
|
||||||
|
duration_max: 10,
|
||||||
|
material_type: "video",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -269,7 +279,12 @@ test.describe("剪辑计划 - API 操作", () => {
|
|||||||
mode: "voice_over",
|
mode: "voice_over",
|
||||||
estimated_duration: 60,
|
estimated_duration: 60,
|
||||||
segments: [
|
segments: [
|
||||||
{ segment_order: 1, duration_min: 10, duration_max: 30, material_type: "video" },
|
{
|
||||||
|
segment_order: 1,
|
||||||
|
duration_min: 10,
|
||||||
|
duration_max: 30,
|
||||||
|
material_type: "video",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+112
-29
@@ -93,7 +93,8 @@ function mockProducts(count: number, statuses: string[] = ["completed"]) {
|
|||||||
resolution: "1080x1920",
|
resolution: "1080x1920",
|
||||||
file_size: (5 + i) * 1024 * 1024,
|
file_size: (5 + i) * 1024 * 1024,
|
||||||
duplicate_rate: i * 5,
|
duplicate_rate: i * 5,
|
||||||
video_url: status === "completed" ? "https://example.com/video.mp4" : undefined,
|
video_url:
|
||||||
|
status === "completed" ? "https://example.com/video.mp4" : undefined,
|
||||||
thumbnail_url: undefined,
|
thumbnail_url: undefined,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
@@ -218,7 +219,11 @@ test.describe("作品库页面", () => {
|
|||||||
const products = mockProducts(3, ["completed", "processing", "failed"]);
|
const products = mockProducts(3, ["completed", "processing", "failed"]);
|
||||||
await mockProductsApi(page, products);
|
await mockProductsApi(page, products);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/products");
|
await page.goto("/app/products");
|
||||||
|
|
||||||
@@ -255,12 +260,24 @@ test.describe("作品库页面", () => {
|
|||||||
|
|
||||||
const products = [
|
const products = [
|
||||||
{ ...mockProducts(1, ["completed"])[0], title: "已完成作品" },
|
{ ...mockProducts(1, ["completed"])[0], title: "已完成作品" },
|
||||||
{ ...mockProducts(1, ["processing"])[0], title: "处理中作品", id: `mock-prod-${Date.now()}-p` },
|
{
|
||||||
{ ...mockProducts(1, ["failed"])[0], title: "失败作品", id: `mock-prod-${Date.now()}-f` },
|
...mockProducts(1, ["processing"])[0],
|
||||||
|
title: "处理中作品",
|
||||||
|
id: `mock-prod-${Date.now()}-p`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...mockProducts(1, ["failed"])[0],
|
||||||
|
title: "失败作品",
|
||||||
|
id: `mock-prod-${Date.now()}-f`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
await mockProductsApi(page, products);
|
await mockProductsApi(page, products);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/products");
|
await page.goto("/app/products");
|
||||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||||
@@ -276,9 +293,9 @@ test.describe("作品库页面", () => {
|
|||||||
const completedCard = page
|
const completedCard = page
|
||||||
.locator(".xx-product-card")
|
.locator(".xx-product-card")
|
||||||
.filter({ hasText: "已完成作品" });
|
.filter({ hasText: "已完成作品" });
|
||||||
await expect(completedCard.locator(".xx-product-status.completed")).toHaveText(
|
await expect(
|
||||||
"已完成",
|
completedCard.locator(".xx-product-status.completed"),
|
||||||
);
|
).toHaveText("已完成");
|
||||||
|
|
||||||
const processingCard = page
|
const processingCard = page
|
||||||
.locator(".xx-product-card")
|
.locator(".xx-product-card")
|
||||||
@@ -309,7 +326,11 @@ test.describe("作品库页面", () => {
|
|||||||
const productId = products[0].id;
|
const productId = products[0].id;
|
||||||
await mockProductsApi(page, products);
|
await mockProductsApi(page, products);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
// 直接访问详情页
|
// 直接访问详情页
|
||||||
await page.goto(`/app/products/${productId}`);
|
await page.goto(`/app/products/${productId}`);
|
||||||
@@ -337,7 +358,11 @@ test.describe("作品库页面", () => {
|
|||||||
products[0].video_url = "https://example.com/test-video.mp4";
|
products[0].video_url = "https://example.com/test-video.mp4";
|
||||||
await mockProductsApi(page, products);
|
await mockProductsApi(page, products);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/products");
|
await page.goto("/app/products");
|
||||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||||
@@ -356,7 +381,10 @@ test.describe("作品库页面", () => {
|
|||||||
// 播放弹窗出现 - 验证有视频元素或播放器容器
|
// 播放弹窗出现 - 验证有视频元素或播放器容器
|
||||||
// (通过 Mock 的 video_url,video 元素应能渲染)
|
// (通过 Mock 的 video_url,video 元素应能渲染)
|
||||||
const videoEl = page.locator("video");
|
const videoEl = page.locator("video");
|
||||||
const videoVisible = await videoEl.first().isVisible({ timeout: 5000 }).catch(() => false);
|
const videoVisible = await videoEl
|
||||||
|
.first()
|
||||||
|
.isVisible({ timeout: 5000 })
|
||||||
|
.catch(() => false);
|
||||||
// 或弹窗容器可见
|
// 或弹窗容器可见
|
||||||
const modalVisible = await page
|
const modalVisible = await page
|
||||||
.locator(".ant-modal-content")
|
.locator(".ant-modal-content")
|
||||||
@@ -380,7 +408,11 @@ test.describe("作品库页面", () => {
|
|||||||
products[0].title = "下载测试作品";
|
products[0].title = "下载测试作品";
|
||||||
await mockProductsApi(page, products);
|
await mockProductsApi(page, products);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/products");
|
await page.goto("/app/products");
|
||||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||||
@@ -409,7 +441,11 @@ test.describe("作品库页面", () => {
|
|||||||
products[0].title = "处理中下载测试";
|
products[0].title = "处理中下载测试";
|
||||||
await mockProductsApi(page, products);
|
await mockProductsApi(page, products);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/products");
|
await page.goto("/app/products");
|
||||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||||
@@ -484,7 +520,11 @@ test.describe("作品库页面", () => {
|
|||||||
route.continue();
|
route.continue();
|
||||||
});
|
});
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/products");
|
await page.goto("/app/products");
|
||||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||||
@@ -507,9 +547,12 @@ test.describe("作品库页面", () => {
|
|||||||
const { headers } = await createAuthedUser(request, "products-del-api");
|
const { headers } = await createAuthedUser(request, "products-del-api");
|
||||||
|
|
||||||
// 测试删除不存在的产品,验证 API 端点存在
|
// 测试删除不存在的产品,验证 API 端点存在
|
||||||
const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, {
|
const resp = await request.delete(
|
||||||
headers,
|
`${apiBase}/products/nonexistent-test-id`,
|
||||||
});
|
{
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在)
|
// 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在)
|
||||||
// 404 表示资源不存在但端点存在
|
// 404 表示资源不存在但端点存在
|
||||||
@@ -529,7 +572,11 @@ test.describe("作品库页面", () => {
|
|||||||
// Mock 空列表
|
// Mock 空列表
|
||||||
await mockProductsApi(page, []);
|
await mockProductsApi(page, []);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/products");
|
await page.goto("/app/products");
|
||||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||||
@@ -553,12 +600,24 @@ test.describe("作品库页面", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const products = [
|
const products = [
|
||||||
{ ...mockProducts(1, ["completed"])[0], title: "苹果宣传视频", id: `mock-prod-${Date.now()}-apple` },
|
{
|
||||||
{ ...mockProducts(1, ["completed"])[0], title: "香蕉推广视频", id: `mock-prod-${Date.now()}-banana` },
|
...mockProducts(1, ["completed"])[0],
|
||||||
|
title: "苹果宣传视频",
|
||||||
|
id: `mock-prod-${Date.now()}-apple`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...mockProducts(1, ["completed"])[0],
|
||||||
|
title: "香蕉推广视频",
|
||||||
|
id: `mock-prod-${Date.now()}-banana`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
await mockProductsApi(page, products);
|
await mockProductsApi(page, products);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/products");
|
await page.goto("/app/products");
|
||||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||||
@@ -566,7 +625,9 @@ test.describe("作品库页面", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 两个作品都可见
|
// 两个作品都可见
|
||||||
await expect(page.getByText("苹果宣传视频")).toBeVisible({ timeout: 5_000 });
|
await expect(page.getByText("苹果宣传视频")).toBeVisible({
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
await expect(page.getByText("香蕉推广视频")).toBeVisible();
|
await expect(page.getByText("香蕉推广视频")).toBeVisible();
|
||||||
|
|
||||||
// 搜索"苹果"
|
// 搜索"苹果"
|
||||||
@@ -576,7 +637,9 @@ test.describe("作品库页面", () => {
|
|||||||
|
|
||||||
// 清空搜索
|
// 清空搜索
|
||||||
await page.getByPlaceholder("搜索成片名称...").fill("");
|
await page.getByPlaceholder("搜索成片名称...").fill("");
|
||||||
await expect(page.getByText("香蕉推广视频")).toBeVisible({ timeout: 5_000 });
|
await expect(page.getByText("香蕉推广视频")).toBeVisible({
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("作品状态筛选", async ({ page, request }) => {
|
test("作品状态筛选", async ({ page, request }) => {
|
||||||
@@ -587,12 +650,24 @@ test.describe("作品库页面", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const products = [
|
const products = [
|
||||||
{ ...mockProducts(1, ["completed"])[0], title: "已完成筛选", id: `mock-prod-${Date.now()}-done` },
|
{
|
||||||
{ ...mockProducts(1, ["processing"])[0], title: "处理中筛选", id: `mock-prod-${Date.now()}-proc` },
|
...mockProducts(1, ["completed"])[0],
|
||||||
|
title: "已完成筛选",
|
||||||
|
id: `mock-prod-${Date.now()}-done`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...mockProducts(1, ["processing"])[0],
|
||||||
|
title: "处理中筛选",
|
||||||
|
id: `mock-prod-${Date.now()}-proc`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
await mockProductsApi(page, products);
|
await mockProductsApi(page, products);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/products");
|
await page.goto("/app/products");
|
||||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||||
@@ -629,7 +704,11 @@ test.describe("作品库页面", () => {
|
|||||||
products[2].title = "批量测试 3";
|
products[2].title = "批量测试 3";
|
||||||
await mockProductsApi(page, products);
|
await mockProductsApi(page, products);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/app/products");
|
await page.goto("/app/products");
|
||||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||||
@@ -653,8 +732,12 @@ test.describe("作品库页面", () => {
|
|||||||
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible();
|
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible();
|
||||||
|
|
||||||
// 批量按钮存在
|
// 批量按钮存在
|
||||||
await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible();
|
await expect(
|
||||||
await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible();
|
batchBar.getByRole("button", { name: "批量下载" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
batchBar.getByRole("button", { name: "批量删除" }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// 取消选择
|
// 取消选择
|
||||||
await batchBar.getByRole("button", { name: "取消选择" }).click();
|
await batchBar.getByRole("button", { name: "取消选择" }).click();
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
*
|
*
|
||||||
* 每个测试独立,先注册登录获取 auth token。
|
* 每个测试独立,先注册登录获取 auth token。
|
||||||
*/
|
*/
|
||||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
import {
|
||||||
|
expect,
|
||||||
|
test,
|
||||||
|
type APIRequestContext,
|
||||||
|
type Page,
|
||||||
|
} from "@playwright/test";
|
||||||
|
|
||||||
const PASSWORD = "Test123456!";
|
const PASSWORD = "Test123456!";
|
||||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||||
@@ -401,7 +406,10 @@ test.describe("个人设置 - 退出登录", () => {
|
|||||||
test.describe.configure({ timeout: 120_000 });
|
test.describe.configure({ timeout: 120_000 });
|
||||||
|
|
||||||
test("登出 API - 正向", async ({ request }) => {
|
test("登出 API - 正向", async ({ request }) => {
|
||||||
const { headers, email } = await createAuthedUser(request, "profile-logout");
|
const { headers, email } = await createAuthedUser(
|
||||||
|
request,
|
||||||
|
"profile-logout",
|
||||||
|
);
|
||||||
|
|
||||||
const response = await request.post(`${apiBase}/auth/logout`, {
|
const response = await request.post(`${apiBase}/auth/logout`, {
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ test.describe("注册页面", () => {
|
|||||||
await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪");
|
await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪");
|
||||||
|
|
||||||
// 标题/描述
|
// 标题/描述
|
||||||
await expect(page.getByText("创建账户,开启智能视频创作之旅")).toBeVisible();
|
await expect(
|
||||||
|
page.getByText("创建账户,开启智能视频创作之旅"),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// 表单字段
|
// 表单字段
|
||||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||||
@@ -69,7 +71,10 @@ test.describe("注册页面", () => {
|
|||||||
await page.goto("/register");
|
await page.goto("/register");
|
||||||
|
|
||||||
// 直接点击注册按钮
|
// 直接点击注册按钮
|
||||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
await page
|
||||||
|
.locator("button[type='submit']")
|
||||||
|
.filter({ hasText: "注册" })
|
||||||
|
.click();
|
||||||
|
|
||||||
// 应显示必填错误
|
// 应显示必填错误
|
||||||
await expect(page.getByText("请输入邮箱")).toBeVisible();
|
await expect(page.getByText("请输入邮箱")).toBeVisible();
|
||||||
@@ -86,7 +91,10 @@ test.describe("注册页面", () => {
|
|||||||
await page.getByLabel("密码").fill(PASSWORD);
|
await page.getByLabel("密码").fill(PASSWORD);
|
||||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||||
|
|
||||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
await page
|
||||||
|
.locator("button[type='submit']")
|
||||||
|
.filter({ hasText: "注册" })
|
||||||
|
.click();
|
||||||
|
|
||||||
// 应显示邮箱格式错误
|
// 应显示邮箱格式错误
|
||||||
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible();
|
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible();
|
||||||
@@ -100,7 +108,10 @@ test.describe("注册页面", () => {
|
|||||||
await page.getByLabel("密码").fill("123");
|
await page.getByLabel("密码").fill("123");
|
||||||
await page.getByLabel("确认密码").fill("123");
|
await page.getByLabel("确认密码").fill("123");
|
||||||
|
|
||||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
await page
|
||||||
|
.locator("button[type='submit']")
|
||||||
|
.filter({ hasText: "注册" })
|
||||||
|
.click();
|
||||||
|
|
||||||
// 应显示密码长度错误
|
// 应显示密码长度错误
|
||||||
await expect(page.getByText("密码至少 8 个字符")).toBeVisible();
|
await expect(page.getByText("密码至少 8 个字符")).toBeVisible();
|
||||||
@@ -114,7 +125,10 @@ test.describe("注册页面", () => {
|
|||||||
await page.getByLabel("密码").fill(PASSWORD);
|
await page.getByLabel("密码").fill(PASSWORD);
|
||||||
await page.getByLabel("确认密码").fill("Different123!");
|
await page.getByLabel("确认密码").fill("Different123!");
|
||||||
|
|
||||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
await page
|
||||||
|
.locator("button[type='submit']")
|
||||||
|
.filter({ hasText: "注册" })
|
||||||
|
.click();
|
||||||
|
|
||||||
// 应显示密码不一致错误
|
// 应显示密码不一致错误
|
||||||
await expect(page.getByText("两次输入的密码不一致")).toBeVisible();
|
await expect(page.getByText("两次输入的密码不一致")).toBeVisible();
|
||||||
@@ -128,7 +142,10 @@ test.describe("注册页面", () => {
|
|||||||
await page.getByLabel("密码").fill(PASSWORD);
|
await page.getByLabel("密码").fill(PASSWORD);
|
||||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||||
|
|
||||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
await page
|
||||||
|
.locator("button[type='submit']")
|
||||||
|
.filter({ hasText: "注册" })
|
||||||
|
.click();
|
||||||
|
|
||||||
await expect(page.getByText("请输入用户名")).toBeVisible();
|
await expect(page.getByText("请输入用户名")).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -154,10 +171,16 @@ test.describe("注册页面", () => {
|
|||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
await page
|
||||||
|
.locator("button[type='submit']")
|
||||||
|
.filter({ hasText: "注册" })
|
||||||
|
.click();
|
||||||
|
|
||||||
const resp = await registerResponse;
|
const resp = await registerResponse;
|
||||||
expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy();
|
expect(
|
||||||
|
resp.ok(),
|
||||||
|
`注册请求应返回 2xx,实际: ${resp.status()}`,
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
// 注册成功后应跳转到登录页或显示成功消息
|
// 注册成功后应跳转到登录页或显示成功消息
|
||||||
// 页面应停留在可识别的状态(成功提示或跳转)
|
// 页面应停留在可识别的状态(成功提示或跳转)
|
||||||
@@ -199,14 +222,19 @@ test.describe("注册页面", () => {
|
|||||||
await page.getByLabel("密码").fill(PASSWORD);
|
await page.getByLabel("密码").fill(PASSWORD);
|
||||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||||
|
|
||||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
await page
|
||||||
|
.locator("button[type='submit']")
|
||||||
|
.filter({ hasText: "注册" })
|
||||||
|
.click();
|
||||||
|
|
||||||
// 应显示错误提示(通过 antd message 或表单错误)
|
// 应显示错误提示(通过 antd message 或表单错误)
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
// 检查是否有错误消息
|
// 检查是否有错误消息
|
||||||
const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible();
|
const hasError = await page
|
||||||
|
.getByText(/注册失败|已注册|已存在|exists/)
|
||||||
|
.isVisible();
|
||||||
return hasError ? "error_shown" : "waiting";
|
return hasError ? "error_shown" : "waiting";
|
||||||
},
|
},
|
||||||
{ timeout: 10_000 },
|
{ timeout: 10_000 },
|
||||||
@@ -254,7 +282,12 @@ test.describe("注册页面", () => {
|
|||||||
|
|
||||||
// 注册
|
// 注册
|
||||||
await request.post(`${apiBase}/auth/register`, {
|
await request.post(`${apiBase}/auth/register`, {
|
||||||
data: { email, password: PASSWORD, username, display_name: "Reg Auth Test" },
|
data: {
|
||||||
|
email,
|
||||||
|
password: PASSWORD,
|
||||||
|
username,
|
||||||
|
display_name: "Reg Auth Test",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 登录
|
// 登录
|
||||||
@@ -293,6 +326,8 @@ test.describe("注册页面", () => {
|
|||||||
// 注册页对已登录用户也可访问(注册页是公开页面)
|
// 注册页对已登录用户也可访问(注册页是公开页面)
|
||||||
// 验证页面正常渲染
|
// 验证页面正常渲染
|
||||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||||
await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible();
|
await expect(
|
||||||
|
page.locator("button[type='submit']").filter({ hasText: "注册" }),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,12 @@
|
|||||||
*
|
*
|
||||||
* 每个测试独立,先注册登录获取 auth token。
|
* 每个测试独立,先注册登录获取 auth token。
|
||||||
*/
|
*/
|
||||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
import {
|
||||||
|
expect,
|
||||||
|
test,
|
||||||
|
type APIRequestContext,
|
||||||
|
type Page,
|
||||||
|
} from "@playwright/test";
|
||||||
|
|
||||||
const PASSWORD = "Test123456!";
|
const PASSWORD = "Test123456!";
|
||||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||||
@@ -243,8 +248,13 @@ test.describe("订阅套餐页 - 升级交互", () => {
|
|||||||
const url = page.url();
|
const url = page.url();
|
||||||
// 验证页面有响应(跳转到支付或保持在订阅页但有弹窗)
|
// 验证页面有响应(跳转到支付或保持在订阅页但有弹窗)
|
||||||
expect(
|
expect(
|
||||||
url.includes("/subscription/upgrade") || url.includes("/subscription") ||
|
url.includes("/subscription/upgrade") ||
|
||||||
(await page.locator(".ant-modal, [role='dialog']").first().isVisible().catch(() => false)),
|
url.includes("/subscription") ||
|
||||||
|
(await page
|
||||||
|
.locator(".ant-modal, [role='dialog']")
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false)),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -538,13 +548,16 @@ test.describe("订阅 - 支付流程", () => {
|
|||||||
test("创建支付订单 - 正向 API", async ({ request }) => {
|
test("创建支付订单 - 正向 API", async ({ request }) => {
|
||||||
const { headers } = await createAuthedUser(request, "sub-pay-api");
|
const { headers } = await createAuthedUser(request, "sub-pay-api");
|
||||||
|
|
||||||
const response = await request.post(`${apiBase}/subscription/create-order`, {
|
const response = await request.post(
|
||||||
headers,
|
`${apiBase}/subscription/create-order`,
|
||||||
data: {
|
{
|
||||||
plan_id: "pro",
|
headers,
|
||||||
billing_cycle: "monthly",
|
data: {
|
||||||
|
plan_id: "pro",
|
||||||
|
billing_cycle: "monthly",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
// 创建支付订单可能成功或接口不存在
|
// 创建支付订单可能成功或接口不存在
|
||||||
expect(
|
expect(
|
||||||
@@ -560,12 +573,15 @@ test.describe("订阅 - 支付流程", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("未登录创建订单 - 反向", async ({ request }) => {
|
test("未登录创建订单 - 反向", async ({ request }) => {
|
||||||
const response = await request.post(`${apiBase}/subscription/create-order`, {
|
const response = await request.post(
|
||||||
data: {
|
`${apiBase}/subscription/create-order`,
|
||||||
plan_id: "pro",
|
{
|
||||||
billing_cycle: "monthly",
|
data: {
|
||||||
|
plan_id: "pro",
|
||||||
|
billing_cycle: "monthly",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
expect([401, 403, 404]).toContain(response.status());
|
expect([401, 403, 404]).toContain(response.status());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -178,7 +178,10 @@ test.describe("订阅过期处理", () => {
|
|||||||
// 免费用户可能不需要取消,返回 400 或类似错误
|
// 免费用户可能不需要取消,返回 400 或类似错误
|
||||||
if (!response.ok()) {
|
if (!response.ok()) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expect(data.error?.message || data.detail || data.message, "应返回错误信息").toBeTruthy();
|
expect(
|
||||||
|
data.error?.message || data.detail || data.message,
|
||||||
|
"应返回错误信息",
|
||||||
|
).toBeTruthy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
*
|
*
|
||||||
* 每个测试独立,先注册登录获取 auth token。
|
* 每个测试独立,先注册登录获取 auth token。
|
||||||
*/
|
*/
|
||||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
import {
|
||||||
|
expect,
|
||||||
|
test,
|
||||||
|
type APIRequestContext,
|
||||||
|
type Page,
|
||||||
|
} from "@playwright/test";
|
||||||
|
|
||||||
const PASSWORD = "Test123456!";
|
const PASSWORD = "Test123456!";
|
||||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||||
@@ -326,7 +331,9 @@ test.describe("模板库 - 模板展示", () => {
|
|||||||
if (await modal.isVisible({ timeout: 5_000 })) {
|
if (await modal.isVisible({ timeout: 5_000 })) {
|
||||||
await expect(modal).toBeVisible();
|
await expect(modal).toBeVisible();
|
||||||
// 验证预览内容存在
|
// 验证预览内容存在
|
||||||
await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible();
|
await expect(
|
||||||
|
modal.locator(".xx-template-modal-title-row"),
|
||||||
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -487,7 +494,10 @@ test.describe("模板库 - API 操作", () => {
|
|||||||
`${apiBase}/templates/${templateId}/favorite`,
|
`${apiBase}/templates/${templateId}/favorite`,
|
||||||
{ headers },
|
{ headers },
|
||||||
);
|
);
|
||||||
expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy();
|
expect(
|
||||||
|
unfavResp.status() < 500,
|
||||||
|
"取消收藏请求应返回 2xx 或 4xx",
|
||||||
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("获取模板详情 - 正向", async ({ request }) => {
|
test("获取模板详情 - 正向", async ({ request }) => {
|
||||||
@@ -515,10 +525,9 @@ test.describe("模板库 - API 操作", () => {
|
|||||||
expect(createResp.ok()).toBeTruthy();
|
expect(createResp.ok()).toBeTruthy();
|
||||||
const created = await createResp.json();
|
const created = await createResp.json();
|
||||||
|
|
||||||
const detailResp = await request.get(
|
const detailResp = await request.get(`${apiBase}/templates/${created.id}`, {
|
||||||
`${apiBase}/templates/${created.id}`,
|
headers,
|
||||||
{ headers },
|
});
|
||||||
);
|
|
||||||
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
|
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
|
||||||
const detail = await detailResp.json();
|
const detail = await detailResp.json();
|
||||||
expect(detail.id).toBe(created.id);
|
expect(detail.id).toBe(created.id);
|
||||||
|
|||||||
@@ -175,10 +175,9 @@ test.describe("认证流程", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain(
|
||||||
[400, 422],
|
response.status(),
|
||||||
"缺少用户名字段应返回 4xx 校验错误",
|
);
|
||||||
).toContain(response.status());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── 登录 ────────────────────────────────────────────
|
// ─── 登录 ────────────────────────────────────────────
|
||||||
@@ -230,7 +229,9 @@ test.describe("认证流程", () => {
|
|||||||
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
|
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
|
||||||
});
|
});
|
||||||
if (response.status() !== 429) break;
|
if (response.status() !== 429) break;
|
||||||
console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`);
|
console.log(
|
||||||
|
`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`,
|
||||||
|
);
|
||||||
await new Promise((r) => setTimeout(r, 65_000));
|
await new Promise((r) => setTimeout(r, 65_000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,12 @@
|
|||||||
*
|
*
|
||||||
* 每个测试独立,先注册登录获取 auth token。
|
* 每个测试独立,先注册登录获取 auth token。
|
||||||
*/
|
*/
|
||||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
import {
|
||||||
|
expect,
|
||||||
|
test,
|
||||||
|
type APIRequestContext,
|
||||||
|
type Page,
|
||||||
|
} from "@playwright/test";
|
||||||
|
|
||||||
const PASSWORD = "Test123456!";
|
const PASSWORD = "Test123456!";
|
||||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||||
@@ -224,7 +229,11 @@ test.describe("标题库 - API 完整操作", () => {
|
|||||||
|
|
||||||
test("编辑标题 - 正向", async ({ request }) => {
|
test("编辑标题 - 正向", async ({ request }) => {
|
||||||
const { headers } = await createAuthedUser(request, "title-update");
|
const { headers } = await createAuthedUser(request, "title-update");
|
||||||
const titleId = await createTitle(request, headers, Date.now().toString(36));
|
const titleId = await createTitle(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
Date.now().toString(36),
|
||||||
|
);
|
||||||
|
|
||||||
const newName = `更新后的标题 ${Date.now()}`;
|
const newName = `更新后的标题 ${Date.now()}`;
|
||||||
const newText = "这是更新后的标题内容";
|
const newText = "这是更新后的标题内容";
|
||||||
@@ -256,7 +265,11 @@ test.describe("标题库 - API 完整操作", () => {
|
|||||||
|
|
||||||
test("删除标题 - 正向", async ({ request }) => {
|
test("删除标题 - 正向", async ({ request }) => {
|
||||||
const { headers } = await createAuthedUser(request, "title-delete");
|
const { headers } = await createAuthedUser(request, "title-delete");
|
||||||
const titleId = await createTitle(request, headers, Date.now().toString(36));
|
const titleId = await createTitle(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
Date.now().toString(36),
|
||||||
|
);
|
||||||
|
|
||||||
// 删除
|
// 删除
|
||||||
const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, {
|
const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, {
|
||||||
@@ -279,9 +292,21 @@ test.describe("标题库 - API 完整操作", () => {
|
|||||||
const suffix = Date.now().toString(36);
|
const suffix = Date.now().toString(36);
|
||||||
|
|
||||||
const titles = [
|
const titles = [
|
||||||
{ name: `批量标题 1 ${suffix}`, text: `内容 1 ${suffix}`, category: "default" },
|
{
|
||||||
{ name: `批量标题 2 ${suffix}`, text: `内容 2 ${suffix}`, category: "种草" },
|
name: `批量标题 1 ${suffix}`,
|
||||||
{ name: `批量标题 3 ${suffix}`, text: `内容 3 ${suffix}`, category: "知识" },
|
text: `内容 1 ${suffix}`,
|
||||||
|
category: "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `批量标题 2 ${suffix}`,
|
||||||
|
text: `内容 2 ${suffix}`,
|
||||||
|
category: "种草",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `批量标题 3 ${suffix}`,
|
||||||
|
text: `内容 3 ${suffix}`,
|
||||||
|
category: "知识",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const response = await request.post(`${apiBase}/titles/batch-import`, {
|
const response = await request.post(`${apiBase}/titles/batch-import`, {
|
||||||
@@ -297,7 +322,9 @@ test.describe("标题库 - API 完整操作", () => {
|
|||||||
|
|
||||||
if (response.ok()) {
|
if (response.ok()) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy();
|
expect(
|
||||||
|
Array.isArray(data) || data.success_count !== undefined,
|
||||||
|
).toBeTruthy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
*
|
*
|
||||||
* 每个测试独立,先注册登录获取 auth token。
|
* 每个测试独立,先注册登录获取 auth token。
|
||||||
*/
|
*/
|
||||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
import {
|
||||||
|
expect,
|
||||||
|
test,
|
||||||
|
type APIRequestContext,
|
||||||
|
type Page,
|
||||||
|
} from "@playwright/test";
|
||||||
|
|
||||||
const PASSWORD = "Test123456!";
|
const PASSWORD = "Test123456!";
|
||||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||||
@@ -326,10 +331,9 @@ test.describe("声音克隆 - API 操作", () => {
|
|||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
|
|
||||||
// 验证已删除
|
// 验证已删除
|
||||||
const getResp = await request.get(
|
const getResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, {
|
||||||
`${apiBase}/voice-clones/${cloneId}`,
|
headers,
|
||||||
{ headers },
|
});
|
||||||
);
|
|
||||||
expect([404, 410]).toContain(getResp.status());
|
expect([404, 410]).toContain(getResp.status());
|
||||||
}
|
}
|
||||||
// 如果创建失败(比如音频格式问题),测试也通过
|
// 如果创建失败(比如音频格式问题),测试也通过
|
||||||
@@ -491,11 +495,15 @@ test.describe("声音克隆 - 上传区域", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 尝试点击克隆新音色按钮
|
// 尝试点击克隆新音色按钮
|
||||||
const cloneBtn = page.getByRole("button", { name: /克隆新音色|立即克隆|新建/ });
|
const cloneBtn = page.getByRole("button", {
|
||||||
|
name: /克隆新音色|立即克隆|新建/,
|
||||||
|
});
|
||||||
if (await cloneBtn.isVisible()) {
|
if (await cloneBtn.isVisible()) {
|
||||||
await cloneBtn.click();
|
await cloneBtn.click();
|
||||||
// 弹窗应该出现
|
// 弹窗应该出现
|
||||||
const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']");
|
const modal = page.locator(
|
||||||
|
".ant-modal, .vc-edit-dialog, [role='dialog']",
|
||||||
|
);
|
||||||
if (await modal.first().isVisible({ timeout: 5_000 })) {
|
if (await modal.first().isVisible({ timeout: 5_000 })) {
|
||||||
await expect(modal.first()).toBeVisible();
|
await expect(modal.first()).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
*
|
*
|
||||||
* 每个测试独立,先注册登录获取 auth token。
|
* 每个测试独立,先注册登录获取 auth token。
|
||||||
*/
|
*/
|
||||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
import {
|
||||||
|
expect,
|
||||||
|
test,
|
||||||
|
type APIRequestContext,
|
||||||
|
type Page,
|
||||||
|
} from "@playwright/test";
|
||||||
|
|
||||||
const PASSWORD = "Test123456!";
|
const PASSWORD = "Test123456!";
|
||||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||||
@@ -157,7 +162,9 @@ test.describe("音色库页面 - 页面加载", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 验证搜索框存在
|
// 验证搜索框存在
|
||||||
const searchInput = page.locator("input[type='search'], .xx-voices-search input, input[placeholder*='搜索']");
|
const searchInput = page.locator(
|
||||||
|
"input[type='search'], .xx-voices-search input, input[placeholder*='搜索']",
|
||||||
|
);
|
||||||
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 });
|
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export default defineConfig({
|
|||||||
use: {
|
use: {
|
||||||
...devices["Desktop Chrome"],
|
...devices["Desktop Chrome"],
|
||||||
channel: process.env.E2E_BROWSER_CHANNEL || "msedge",
|
channel: process.env.E2E_BROWSER_CHANNEL || "msedge",
|
||||||
|
launchOptions: {
|
||||||
|
args: ["--disable-gpu", "--disable-software-rasterizer"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -51,6 +54,9 @@ export default defineConfig({
|
|||||||
use: {
|
use: {
|
||||||
...devices["Desktop Chrome"],
|
...devices["Desktop Chrome"],
|
||||||
channel: process.env.E2E_BROWSER_CHANNEL || "msedge",
|
channel: process.env.E2E_BROWSER_CHANNEL || "msedge",
|
||||||
|
launchOptions: {
|
||||||
|
args: ["--disable-gpu", "--disable-software-rasterizer"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
/**
|
|
||||||
* 账号管理 Mock API
|
|
||||||
*
|
|
||||||
* 模拟多平台账号绑定/解绑操作
|
|
||||||
* 支持平台:抖音、快手、小红书、微信视频号
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/** 平台 ID */
|
|
||||||
export type PlatformId = "douyin" | "kuaishou" | "xiaohongshu" | "wechat";
|
|
||||||
|
|
||||||
/** 账号状态 */
|
|
||||||
export type AccountStatus = "active" | "expired" | "limited";
|
|
||||||
|
|
||||||
/** 已绑定的账号 */
|
|
||||||
export interface Account {
|
|
||||||
id: string;
|
|
||||||
platform_id: PlatformId;
|
|
||||||
name: string;
|
|
||||||
avatar?: string;
|
|
||||||
status: AccountStatus;
|
|
||||||
bound_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 平台信息 */
|
|
||||||
export interface Platform {
|
|
||||||
id: PlatformId;
|
|
||||||
name: string;
|
|
||||||
subName: string;
|
|
||||||
icon: string;
|
|
||||||
gradient: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 绑定账号请求 */
|
|
||||||
export interface BindAccountRequest {
|
|
||||||
platform_id: PlatformId;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 平台配置 ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export const PLATFORMS: Platform[] = [
|
|
||||||
{
|
|
||||||
id: "douyin",
|
|
||||||
name: "抖音",
|
|
||||||
subName: "短视频发布平台",
|
|
||||||
icon: "📱",
|
|
||||||
gradient: "linear-gradient(135deg, #fe2c55, #25f4ee)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "kuaishou",
|
|
||||||
name: "快手",
|
|
||||||
subName: "短视频发布平台",
|
|
||||||
icon: "🎬",
|
|
||||||
gradient: "linear-gradient(135deg, #ff4906, #ffba00)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "xiaohongshu",
|
|
||||||
name: "小红书",
|
|
||||||
subName: "种草笔记发布平台",
|
|
||||||
icon: "📕",
|
|
||||||
gradient: "linear-gradient(135deg, #ff2442, #ff6b6b)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "wechat",
|
|
||||||
name: "微信视频号",
|
|
||||||
subName: "视频号发布平台",
|
|
||||||
icon: "💬",
|
|
||||||
gradient: "linear-gradient(135deg, #07c160, #4cd964)",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/* ── Mock 数据 ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
let MOCK_ACCOUNTS: Account[] = [
|
|
||||||
{
|
|
||||||
id: "acc-001",
|
|
||||||
platform_id: "douyin",
|
|
||||||
name: "小虾官方号",
|
|
||||||
avatar: "🦐",
|
|
||||||
status: "active",
|
|
||||||
bound_at: "2025-12-01T10:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "acc-002",
|
|
||||||
platform_id: "douyin",
|
|
||||||
name: "小虾日常",
|
|
||||||
avatar: "🐟",
|
|
||||||
status: "active",
|
|
||||||
bound_at: "2025-12-15T14:30:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "acc-003",
|
|
||||||
platform_id: "kuaishou",
|
|
||||||
name: "小虾剪辑",
|
|
||||||
avatar: "🎬",
|
|
||||||
status: "active",
|
|
||||||
bound_at: "2026-01-05T09:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "acc-004",
|
|
||||||
platform_id: "xiaohongshu",
|
|
||||||
name: "小虾种草",
|
|
||||||
avatar: "📕",
|
|
||||||
status: "limited",
|
|
||||||
bound_at: "2026-02-20T16:00:00Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/* ── 模拟延迟 ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
|
|
||||||
/* ── API 函数 ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/** 获取指定平台的账号列表 */
|
|
||||||
export async function getAccountsByPlatform(
|
|
||||||
platformId: PlatformId,
|
|
||||||
): Promise<Account[]> {
|
|
||||||
await delay(300);
|
|
||||||
return MOCK_ACCOUNTS.filter((a) => a.platform_id === platformId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取所有平台的账号总数 */
|
|
||||||
export async function getAllAccounts(): Promise<Account[]> {
|
|
||||||
await delay(200);
|
|
||||||
return [...MOCK_ACCOUNTS];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 绑定新账号 */
|
|
||||||
export async function bindAccount(data: BindAccountRequest): Promise<Account> {
|
|
||||||
await delay(500);
|
|
||||||
const newAccount: Account = {
|
|
||||||
id: `acc-${Date.now()}`,
|
|
||||||
platform_id: data.platform_id,
|
|
||||||
name: data.name,
|
|
||||||
avatar: undefined,
|
|
||||||
status: "active",
|
|
||||||
bound_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
MOCK_ACCOUNTS = [...MOCK_ACCOUNTS, newAccount];
|
|
||||||
return newAccount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 解绑账号 */
|
|
||||||
export async function unbindAccount(accountId: string): Promise<void> {
|
|
||||||
await delay(400);
|
|
||||||
MOCK_ACCOUNTS = MOCK_ACCOUNTS.filter((a) => a.id !== accountId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 状态配置 ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export const ACCOUNT_STATUS_CONFIG: Record<
|
|
||||||
AccountStatus,
|
|
||||||
{ label: string; className: string }
|
|
||||||
> = {
|
|
||||||
active: { label: "正常", className: "acc-status--active" },
|
|
||||||
expired: { label: "已过期", className: "acc-status--expired" },
|
|
||||||
limited: { label: "受限", className: "acc-status--limited" },
|
|
||||||
};
|
|
||||||
@@ -5,6 +5,32 @@
|
|||||||
import apiClient from "./client";
|
import apiClient from "./client";
|
||||||
import { getOrCreateDefaultProject } from "./projects";
|
import { getOrCreateDefaultProject } from "./projects";
|
||||||
|
|
||||||
|
/** 素材元数据 */
|
||||||
|
export interface AssetMetadata {
|
||||||
|
/** 时长(秒) */
|
||||||
|
duration?: number;
|
||||||
|
/** 宽度(像素) */
|
||||||
|
width?: number;
|
||||||
|
/** 高度(像素) */
|
||||||
|
height?: number;
|
||||||
|
/** 比特率(bps) */
|
||||||
|
bitrate?: number;
|
||||||
|
/** 编码格式 */
|
||||||
|
codec?: string;
|
||||||
|
/** 帧率 */
|
||||||
|
fps?: number;
|
||||||
|
/** 采样率(Hz) */
|
||||||
|
sample_rate?: number;
|
||||||
|
/** 声道数 */
|
||||||
|
channels?: number;
|
||||||
|
/** 其他扩展字段 */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 素材分类状态 */
|
||||||
|
export type AssetClassificationStatus =
|
||||||
|
"pending" | "processing" | "completed" | "failed";
|
||||||
|
|
||||||
/** 素材条目 */
|
/** 素材条目 */
|
||||||
export interface AssetItem {
|
export interface AssetItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,12 +38,14 @@ export interface AssetItem {
|
|||||||
name: string;
|
name: string;
|
||||||
storage_key: string;
|
storage_key: string;
|
||||||
mime_type: string;
|
mime_type: string;
|
||||||
metadata: Record<string, unknown>;
|
metadata: AssetMetadata;
|
||||||
file_size?: number;
|
file_size?: number;
|
||||||
file_url?: string;
|
file_url?: string;
|
||||||
thumbnail_url?: string;
|
thumbnail_url?: string;
|
||||||
|
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
|
||||||
|
duration?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
classification_status?: string | null;
|
classification_status?: AssetClassificationStatus | null;
|
||||||
quality_score?: number | null;
|
quality_score?: number | null;
|
||||||
tag_ids?: string[];
|
tag_ids?: string[];
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
@@ -167,7 +195,7 @@ export const createAsset = async (data: {
|
|||||||
name: string;
|
name: string;
|
||||||
storage_key: string;
|
storage_key: string;
|
||||||
mime_type: string;
|
mime_type: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: AssetMetadata;
|
||||||
}): Promise<AssetItem> => {
|
}): Promise<AssetItem> => {
|
||||||
const response = await apiClient.post("/assets", data);
|
const response = await apiClient.post("/assets", data);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -176,7 +204,7 @@ export const createAsset = async (data: {
|
|||||||
/** 更新素材(名称、metadata 等) */
|
/** 更新素材(名称、metadata 等) */
|
||||||
export const updateAsset = async (
|
export const updateAsset = async (
|
||||||
assetId: string,
|
assetId: string,
|
||||||
data: { name?: string; metadata?: Record<string, unknown> },
|
data: { name?: string; metadata?: AssetMetadata },
|
||||||
): Promise<AssetItem> => {
|
): Promise<AssetItem> => {
|
||||||
const response = await apiClient.put(`/assets/${assetId}`, data);
|
const response = await apiClient.put(`/assets/${assetId}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -350,3 +378,52 @@ export const getClassificationJob = async (
|
|||||||
const response = await apiClient.get(`/classification-jobs/${jobId}`);
|
const response = await apiClient.get(`/classification-jobs/${jobId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── 批量操作 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 批量操作结果 */
|
||||||
|
export interface BatchOperationResult {
|
||||||
|
succeeded: string[];
|
||||||
|
failed: string[];
|
||||||
|
total: number;
|
||||||
|
success_count: number;
|
||||||
|
failure_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量删除素材 */
|
||||||
|
export const batchDeleteAssets = async (
|
||||||
|
assetIds: string[],
|
||||||
|
): Promise<BatchOperationResult> => {
|
||||||
|
const response = await apiClient.post("/assets/batch-delete", {
|
||||||
|
asset_ids: assetIds,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 批量打标签 */
|
||||||
|
export const batchTagAssets = async (data: {
|
||||||
|
asset_ids: string[];
|
||||||
|
tags: string[];
|
||||||
|
mode: "add" | "replace";
|
||||||
|
}): Promise<BatchOperationResult> => {
|
||||||
|
const response = await apiClient.post("/assets/batch-tag", data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 批量改分类 */
|
||||||
|
export const batchClassifyAssets = async (data: {
|
||||||
|
asset_ids: string[];
|
||||||
|
category: string;
|
||||||
|
}): Promise<BatchOperationResult> => {
|
||||||
|
const response = await apiClient.post("/assets/batch-classify", data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 批量智能标记 */
|
||||||
|
export const batchMarkAssets = async (data: {
|
||||||
|
asset_ids: string[];
|
||||||
|
smart_view: "recommended" | "caution" | "high_risk";
|
||||||
|
}): Promise<BatchOperationResult> => {
|
||||||
|
const response = await apiClient.post("/assets/batch-mark", data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* BGM 预设音乐 API
|
||||||
|
* 对接后端 BGM 混音能力:预设列表查询(按风格分类 + 关键词搜索)
|
||||||
|
*/
|
||||||
|
import apiClient from "./client";
|
||||||
|
|
||||||
|
/* ──────────── 类型 ──────────── */
|
||||||
|
|
||||||
|
/** BGM 风格分类 */
|
||||||
|
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商";
|
||||||
|
|
||||||
|
/** BGM 预设项 */
|
||||||
|
export interface BgmPreset {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: BgmCategory;
|
||||||
|
/** 音频文件 URL */
|
||||||
|
url: string;
|
||||||
|
/** 时长(秒) */
|
||||||
|
duration: number;
|
||||||
|
/** 关键词标签 */
|
||||||
|
tags: string[];
|
||||||
|
/** 封面图 URL */
|
||||||
|
cover_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BGM 预设列表查询参数 */
|
||||||
|
export interface BgmPresetsQuery {
|
||||||
|
category?: BgmCategory | string;
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BGM 混音配置(嵌入剪辑计划) */
|
||||||
|
export interface BgmMixConfig {
|
||||||
|
/** 是否启用 BGM */
|
||||||
|
enabled: boolean;
|
||||||
|
/** 选中的 BGM ID */
|
||||||
|
music_id: string;
|
||||||
|
/** BGM 音量 0-100 */
|
||||||
|
volume: number;
|
||||||
|
/** 淡入时长(秒) 0-3 */
|
||||||
|
fade_in: number;
|
||||||
|
/** 淡出时长(秒) 0-3 */
|
||||||
|
fade_out: number;
|
||||||
|
/** 人声闪避(sidechain) */
|
||||||
|
voice_dodge: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认 BGM 混音配置 */
|
||||||
|
export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = {
|
||||||
|
enabled: false,
|
||||||
|
music_id: "",
|
||||||
|
volume: 50,
|
||||||
|
fade_in: 0.5,
|
||||||
|
fade_out: 0.5,
|
||||||
|
voice_dodge: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ──────────── API ──────────── */
|
||||||
|
|
||||||
|
/** 获取 BGM 预设列表 */
|
||||||
|
export const getBgmPresets = async (
|
||||||
|
params?: BgmPresetsQuery,
|
||||||
|
): Promise<BgmPreset[]> => {
|
||||||
|
const searchParams: Record<string, string> = {};
|
||||||
|
if (params?.category) searchParams.category = params.category;
|
||||||
|
if (params?.keyword) searchParams.keyword = params.keyword;
|
||||||
|
const res = await apiClient.get("/bgm/presets", { params: searchParams });
|
||||||
|
return res.data?.data ?? res.data ?? [];
|
||||||
|
};
|
||||||
@@ -129,7 +129,8 @@ apiClient.interceptors.response.use(
|
|||||||
const safeExtractString = (val: unknown): string => {
|
const safeExtractString = (val: unknown): string => {
|
||||||
if (typeof val === "string") return val;
|
if (typeof val === "string") return val;
|
||||||
if (typeof val === "object" && val !== null) {
|
if (typeof val === "object" && val !== null) {
|
||||||
const obj = val as Record<string, unknown>;
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||||
|
const obj = val as Record<string, any>;
|
||||||
if (typeof obj.message === "string") return obj.message;
|
if (typeof obj.message === "string") return obj.message;
|
||||||
if (typeof obj.msg === "string") return obj.msg;
|
if (typeof obj.msg === "string") return obj.msg;
|
||||||
if (typeof obj.detail === "string") return obj.detail;
|
if (typeof obj.detail === "string") return obj.detail;
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
/**
|
|
||||||
* 仪表盘 API
|
|
||||||
* Phase 1 新增:用户仪表盘概览
|
|
||||||
*/
|
|
||||||
import apiClient from "./client";
|
|
||||||
|
|
||||||
/** 仪表盘概览数据 */
|
|
||||||
export interface DashboardOverview {
|
|
||||||
/** 素材总数 */
|
|
||||||
total_assets: number;
|
|
||||||
/** 已用存储(字节) */
|
|
||||||
used_storage_bytes: number;
|
|
||||||
/** 总标题数 */
|
|
||||||
total_titles: number;
|
|
||||||
/** 总配音数 */
|
|
||||||
total_voices: number;
|
|
||||||
/** 生成任务总数 */
|
|
||||||
total_tasks: number;
|
|
||||||
/** 成品总数 */
|
|
||||||
total_products: number;
|
|
||||||
/** 最近生成任务 */
|
|
||||||
recent_tasks: Array<{
|
|
||||||
id: string;
|
|
||||||
task_type: string;
|
|
||||||
status: string;
|
|
||||||
progress: number;
|
|
||||||
user_message: string;
|
|
||||||
created_at: string;
|
|
||||||
}>;
|
|
||||||
/** 订阅信息 */
|
|
||||||
subscription: {
|
|
||||||
plan: "free" | "pro" | "enterprise";
|
|
||||||
status: "active" | "inactive" | "expired";
|
|
||||||
expires_at?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取仪表盘概览数据 */
|
|
||||||
export const getDashboardOverview = async (): Promise<DashboardOverview> => {
|
|
||||||
const response = await apiClient.get("/dashboard/overview");
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
+216
-32
@@ -4,6 +4,15 @@
|
|||||||
*/
|
*/
|
||||||
import apiClient from "./client";
|
import apiClient from "./client";
|
||||||
import type { AssetItem } from "./assets";
|
import type { AssetItem } from "./assets";
|
||||||
|
import type {
|
||||||
|
WatermarkConfig,
|
||||||
|
IntroOutroConfig,
|
||||||
|
PipConfig,
|
||||||
|
FilterConfig,
|
||||||
|
ChromaKeyConfig,
|
||||||
|
StickerConfig,
|
||||||
|
CoverConfig,
|
||||||
|
} from "@/pages/editing-planner/types";
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 后端 API 类型(严格匹配后端 Schema)
|
* 后端 API 类型(严格匹配后端 Schema)
|
||||||
@@ -13,6 +22,107 @@ import type { AssetItem } from "./assets";
|
|||||||
export type EditPlanStatus =
|
export type EditPlanStatus =
|
||||||
"draft" | "editing" | "rendering" | "completed" | "failed";
|
"draft" | "editing" | "rendering" | "completed" | "failed";
|
||||||
|
|
||||||
|
/** 标题配置(对齐后端 title_config) */
|
||||||
|
export interface TitleConfig {
|
||||||
|
ai_auto_select: boolean;
|
||||||
|
content: string;
|
||||||
|
font_preset: string;
|
||||||
|
font_color: string;
|
||||||
|
font_size: number;
|
||||||
|
position: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字幕配置 */
|
||||||
|
export interface SubtitleConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
position: string;
|
||||||
|
font: string;
|
||||||
|
color: string;
|
||||||
|
size: number;
|
||||||
|
animation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BGM 配置 */
|
||||||
|
export interface BgmConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
music_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 片段 TTS 配置 */
|
||||||
|
export interface SegmentTtsConfig {
|
||||||
|
mode: string;
|
||||||
|
text: string;
|
||||||
|
voice_id: string;
|
||||||
|
speed: number;
|
||||||
|
pitch: number;
|
||||||
|
volume: number;
|
||||||
|
subtitle_sync: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 片段裁剪配置 */
|
||||||
|
export interface SegmentTrimConfig {
|
||||||
|
start_time: number;
|
||||||
|
end_time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 片段转场配置 */
|
||||||
|
export interface SegmentTransitionConfig {
|
||||||
|
type: string;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 剪辑计划中的单个片段(config 内部 segments 项) */
|
||||||
|
export interface EditPlanSegment {
|
||||||
|
segment_order: number;
|
||||||
|
duration_min: number;
|
||||||
|
duration_max: number;
|
||||||
|
material_type: string;
|
||||||
|
transition?: SegmentTransitionConfig;
|
||||||
|
playback_speed?: number;
|
||||||
|
tts_config?: SegmentTtsConfig;
|
||||||
|
trim_config?: SegmentTrimConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 剪辑计划 config 完整类型(对齐后端 config JSON 结构) */
|
||||||
|
export interface EditPlanConfig {
|
||||||
|
title_config?: TitleConfig;
|
||||||
|
subtitle_config?: SubtitleConfig;
|
||||||
|
bgm_config?: BgmConfig;
|
||||||
|
estimated_duration?: number;
|
||||||
|
segments?: EditPlanSegment[];
|
||||||
|
watermark_config?: WatermarkConfig;
|
||||||
|
intro_outro_config?: IntroOutroConfig;
|
||||||
|
pip_config?: PipConfig;
|
||||||
|
filter_config?: FilterConfig;
|
||||||
|
green_screen_config?: ChromaKeyConfig;
|
||||||
|
sticker_config?: StickerConfig;
|
||||||
|
cover_config?: CoverConfig;
|
||||||
|
/** 前端扩展:关联的素材 ID 列表 */
|
||||||
|
asset_ids?: string[];
|
||||||
|
/** 配音 ID */
|
||||||
|
voice_id?: string;
|
||||||
|
/** 克隆音色档案 ID */
|
||||||
|
voice_clone_profile_id?: string;
|
||||||
|
/** 自定义配音音频 URL */
|
||||||
|
custom_audio_url?: string;
|
||||||
|
/** 自定义配音文本 */
|
||||||
|
custom_text?: string;
|
||||||
|
/** 视频比例 */
|
||||||
|
ratio?: string;
|
||||||
|
/** 视频风格 */
|
||||||
|
style?: string;
|
||||||
|
/** 目标时长(秒) */
|
||||||
|
duration?: number;
|
||||||
|
/** 是否自动生成字幕 */
|
||||||
|
auto_subtitles?: boolean;
|
||||||
|
/** 是否启用 BGM */
|
||||||
|
bgm?: boolean;
|
||||||
|
/** 生成数量 */
|
||||||
|
generate_count?: number;
|
||||||
|
/** 素材模式 */
|
||||||
|
material_mode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** 剪辑计划(后端响应) */
|
/** 剪辑计划(后端响应) */
|
||||||
export interface EditPlan {
|
export interface EditPlan {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,7 +130,7 @@ export interface EditPlan {
|
|||||||
name: string;
|
name: string;
|
||||||
status: EditPlanStatus;
|
status: EditPlanStatus;
|
||||||
total_duration: number;
|
total_duration: number;
|
||||||
config: Record<string, unknown>;
|
config: EditPlanConfig;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -29,7 +139,7 @@ export interface EditPlan {
|
|||||||
export interface CreateEditPlanRequest {
|
export interface CreateEditPlanRequest {
|
||||||
template_id: string;
|
template_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
config?: Record<string, unknown>;
|
config?: EditPlanConfig;
|
||||||
total_duration?: number;
|
total_duration?: number;
|
||||||
/** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */
|
/** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */
|
||||||
source_edit_plan_id?: string;
|
source_edit_plan_id?: string;
|
||||||
@@ -38,7 +148,7 @@ export interface CreateEditPlanRequest {
|
|||||||
/** 更新剪辑计划请求 */
|
/** 更新剪辑计划请求 */
|
||||||
export interface UpdateEditPlanRequest {
|
export interface UpdateEditPlanRequest {
|
||||||
name?: string;
|
name?: string;
|
||||||
config?: Record<string, unknown>;
|
config?: EditPlanConfig;
|
||||||
total_duration?: number;
|
total_duration?: number;
|
||||||
status?: EditPlanStatus;
|
status?: EditPlanStatus;
|
||||||
}
|
}
|
||||||
@@ -80,6 +190,26 @@ export interface GenerationStatusResponse {
|
|||||||
clips: ClipStatusItem[];
|
clips: ClipStatusItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 生成视频详情(对应后端 GeneratedVideoResponse) */
|
||||||
|
export interface GeneratedVideo {
|
||||||
|
id: string;
|
||||||
|
project_id?: string;
|
||||||
|
generation_task_id?: string;
|
||||||
|
name: string;
|
||||||
|
file_url: string;
|
||||||
|
file_size?: number;
|
||||||
|
duration?: number;
|
||||||
|
thumbnail_url?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
fps?: number;
|
||||||
|
status: string;
|
||||||
|
review_status?: string;
|
||||||
|
download_url?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* AI 推荐 & 封面生成(任务 3.09)
|
* AI 推荐 & 封面生成(任务 3.09)
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
@@ -100,14 +230,14 @@ export interface AIRecommendClipItem {
|
|||||||
transition_effect: string;
|
transition_effect: string;
|
||||||
asset_id: string;
|
asset_id: string;
|
||||||
start_time: number;
|
start_time: number;
|
||||||
config: Record<string, unknown>;
|
config: EditPlanConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** AI 推荐响应 */
|
/** AI 推荐响应 */
|
||||||
export interface AIRecommendResponse {
|
export interface AIRecommendResponse {
|
||||||
plan_id: string;
|
plan_id: string;
|
||||||
clips: AIRecommendClipItem[];
|
clips: AIRecommendClipItem[];
|
||||||
config: Record<string, unknown>;
|
config: EditPlanConfig;
|
||||||
total_duration: number;
|
total_duration: number;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
}
|
}
|
||||||
@@ -122,7 +252,15 @@ export interface GenerateCoverRequest {
|
|||||||
/** AI 封面生成响应 */
|
/** AI 封面生成响应 */
|
||||||
export interface GenerateCoverResponse {
|
export interface GenerateCoverResponse {
|
||||||
plan_id: string;
|
plan_id: string;
|
||||||
cover: Record<string, unknown>;
|
cover: CoverResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 封面生成结果 */
|
||||||
|
export interface CoverResult {
|
||||||
|
scheme?: string;
|
||||||
|
asset_id?: string;
|
||||||
|
frame_time?: number;
|
||||||
|
thumbnail_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -147,10 +285,27 @@ export interface EditPlanClip {
|
|||||||
order: number;
|
order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 转场效果 */
|
/** 转场效果(14 种预设) */
|
||||||
export interface TransitionEffect {
|
export interface TransitionEffect {
|
||||||
type: "none" | "fade" | "dissolve" | "wipe" | "zoom" | "slide";
|
type:
|
||||||
|
| "none"
|
||||||
|
| "cut"
|
||||||
|
| "fade"
|
||||||
|
| "dissolve"
|
||||||
|
| "zoom"
|
||||||
|
| "slide_left"
|
||||||
|
| "slide_right"
|
||||||
|
| "slide_up"
|
||||||
|
| "slide_down"
|
||||||
|
| "wipe_left"
|
||||||
|
| "wipe_right"
|
||||||
|
| "wipe_up"
|
||||||
|
| "wipe_down"
|
||||||
|
| "circlecrop"
|
||||||
|
| "rectcrop";
|
||||||
duration: number; // 转场时长(秒)
|
duration: number; // 转场时长(秒)
|
||||||
|
/** 播放速度倍率 */
|
||||||
|
playback_speed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 素材库资产(UI 层类型,映射自后端 AssetResponse) */
|
/** 素材库资产(UI 层类型,映射自后端 AssetResponse) */
|
||||||
@@ -177,15 +332,30 @@ export interface MediaAsset {
|
|||||||
* API 函数 — 严格对接后端
|
* API 函数 — 严格对接后端
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
/** 获取剪辑计划列表 */
|
/** 剪辑计划列表查询参数 */
|
||||||
export async function getEditPlans(params?: {
|
export interface EditPlanListParams {
|
||||||
page?: number;
|
page?: number;
|
||||||
page_size?: number;
|
page_size?: number;
|
||||||
template_id?: string;
|
template_id?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
}): Promise<EditPlan[]> {
|
}
|
||||||
const response = await apiClient.get("/edit-plans", { params });
|
|
||||||
return response.data.items || [];
|
/** 剪辑计划列表分页响应 */
|
||||||
|
export interface EditPlanListResponse {
|
||||||
|
items: EditPlan[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取剪辑计划列表(支持分页和筛选) */
|
||||||
|
export async function getEditPlans(
|
||||||
|
params?: EditPlanListParams,
|
||||||
|
): Promise<EditPlanListResponse> {
|
||||||
|
const response = await apiClient.get<EditPlanListResponse>("/edit-plans", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取单个剪辑计划 */
|
/** 获取单个剪辑计划 */
|
||||||
@@ -266,6 +436,14 @@ export async function getEditPlanGenerations(
|
|||||||
return response.data.items || [];
|
return response.data.items || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取生成任务的视频结果列表 */
|
||||||
|
export async function getGenerationTaskResults(
|
||||||
|
taskId: string,
|
||||||
|
): Promise<GeneratedVideo[]> {
|
||||||
|
const response = await apiClient.get(`/generation/tasks/${taskId}/results`);
|
||||||
|
return response.data.items || response.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
|
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
|
||||||
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
|
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
|
||||||
@@ -297,26 +475,22 @@ function inferMediaType(mimeType: string): "video" | "image" | "audio" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
||||||
const meta = (asset.metadata || {}) as Record<string, unknown>;
|
// 优先取顶层 duration,其次从 metadata 回退
|
||||||
const ext = asset as AssetItem & Record<string, unknown>;
|
const metaDuration =
|
||||||
|
typeof asset.metadata?.duration === "number"
|
||||||
|
? asset.metadata.duration
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
name: asset.name,
|
name: asset.name,
|
||||||
type: inferMediaType(asset.mime_type || ""),
|
type: inferMediaType(asset.mime_type || ""),
|
||||||
thumbnail_url:
|
thumbnail_url: asset.thumbnail_url,
|
||||||
typeof ext.thumbnail_url === "string" ? ext.thumbnail_url : undefined,
|
duration: asset.duration ?? metaDuration,
|
||||||
duration:
|
|
||||||
typeof ext.duration === "number"
|
|
||||||
? ext.duration
|
|
||||||
: typeof meta.duration === "number"
|
|
||||||
? (meta.duration as number)
|
|
||||||
: undefined,
|
|
||||||
size: asset.file_size ?? undefined,
|
size: asset.file_size ?? undefined,
|
||||||
tags: [],
|
tags: [],
|
||||||
created_at: asset.created_at ?? "",
|
created_at: asset.created_at ?? "",
|
||||||
quality_score: asset.quality_score ?? undefined,
|
quality_score: asset.quality_score ?? undefined,
|
||||||
classification_status: (asset.classification_status ??
|
classification_status: asset.classification_status ?? undefined,
|
||||||
undefined) as MediaAsset["classification_status"],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,17 +498,27 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
|||||||
* 常量
|
* 常量
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
/** 转场效果选项 */
|
/** 转场效果选项(14 种预设) */
|
||||||
export const TRANSITION_OPTIONS: {
|
export const TRANSITION_OPTIONS: {
|
||||||
value: TransitionEffect["type"];
|
value: TransitionEffect["type"];
|
||||||
label: string;
|
label: string;
|
||||||
|
icon: string;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ value: "none", label: "无转场" },
|
{ value: "none", label: "无转场", icon: "⊘" },
|
||||||
{ value: "fade", label: "淡入淡出" },
|
{ value: "cut", label: "硬切", icon: "✂" },
|
||||||
{ value: "dissolve", label: "溶解" },
|
{ value: "fade", label: "淡入淡出", icon: "◐" },
|
||||||
{ value: "wipe", label: "擦除" },
|
{ value: "dissolve", label: "溶解", icon: "◈" },
|
||||||
{ value: "zoom", label: "缩放" },
|
{ value: "zoom", label: "缩放", icon: "⊕" },
|
||||||
{ value: "slide", label: "滑动" },
|
{ value: "slide_left", label: "左滑", icon: "←" },
|
||||||
|
{ value: "slide_right", label: "右滑", icon: "→" },
|
||||||
|
{ value: "slide_up", label: "上滑", icon: "↑" },
|
||||||
|
{ value: "slide_down", label: "下滑", icon: "↓" },
|
||||||
|
{ value: "wipe_left", label: "左擦除", icon: "▸|" },
|
||||||
|
{ value: "wipe_right", label: "右擦除", icon: "|◂" },
|
||||||
|
{ value: "wipe_up", label: "上擦除", icon: "▴̄" },
|
||||||
|
{ value: "wipe_down", label: "下擦除", icon: "▾̄" },
|
||||||
|
{ value: "circlecrop", label: "圆形裁切", icon: "●" },
|
||||||
|
{ value: "rectcrop", label: "矩形裁切", icon: "■" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 素材类型标签 */
|
/** 素材类型标签 */
|
||||||
|
|||||||
@@ -3,6 +3,15 @@
|
|||||||
* 对接后端 /api/v1/templates 路由
|
* 对接后端 /api/v1/templates 路由
|
||||||
*/
|
*/
|
||||||
import apiClient from "./client";
|
import apiClient from "./client";
|
||||||
|
import type {
|
||||||
|
WatermarkConfig,
|
||||||
|
IntroOutroConfig,
|
||||||
|
PipConfig,
|
||||||
|
FilterConfig,
|
||||||
|
ChromaKeyConfig,
|
||||||
|
StickerConfig,
|
||||||
|
CoverConfig,
|
||||||
|
} from "@/pages/editing-planner/types";
|
||||||
|
|
||||||
/* ──────────── 类型定义 ──────────── */
|
/* ──────────── 类型定义 ──────────── */
|
||||||
|
|
||||||
@@ -72,6 +81,20 @@ export interface EditingTemplate {
|
|||||||
bgm_config: BgmConfig;
|
bgm_config: BgmConfig;
|
||||||
estimated_duration: number;
|
estimated_duration: number;
|
||||||
segments: TemplateSegment[];
|
segments: TemplateSegment[];
|
||||||
|
/** 水印配置(后端就绪后启用) */
|
||||||
|
watermark_config?: WatermarkConfig;
|
||||||
|
/** 片头片尾配置(后端就绪后启用) */
|
||||||
|
intro_outro_config?: IntroOutroConfig;
|
||||||
|
/** 画中画配置 */
|
||||||
|
pip_config?: PipConfig;
|
||||||
|
/** 滤镜调色配置 */
|
||||||
|
filter_config?: FilterConfig;
|
||||||
|
/** 绿幕抠像配置 */
|
||||||
|
green_screen_config?: ChromaKeyConfig;
|
||||||
|
/** 贴纸配置 */
|
||||||
|
sticker_config?: StickerConfig;
|
||||||
|
/** 封面配置 */
|
||||||
|
cover_config?: CoverConfig;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -95,6 +118,20 @@ export interface SaveTemplatePayload {
|
|||||||
bgm_config: BgmConfig;
|
bgm_config: BgmConfig;
|
||||||
estimated_duration: number;
|
estimated_duration: number;
|
||||||
segments: Omit<TemplateSegment, "id">[];
|
segments: Omit<TemplateSegment, "id">[];
|
||||||
|
/** 水印配置(后端就绪后启用) */
|
||||||
|
watermark_config?: WatermarkConfig;
|
||||||
|
/** 片头片尾配置(后端就绪后启用) */
|
||||||
|
intro_outro_config?: IntroOutroConfig;
|
||||||
|
/** 画中画配置 */
|
||||||
|
pip_config?: PipConfig;
|
||||||
|
/** 滤镜调色配置 */
|
||||||
|
filter_config?: FilterConfig;
|
||||||
|
/** 绿幕抠像配置 */
|
||||||
|
green_screen_config?: ChromaKeyConfig;
|
||||||
|
/** 贴纸配置 */
|
||||||
|
sticker_config?: StickerConfig;
|
||||||
|
/** 封面配置 */
|
||||||
|
cover_config?: CoverConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 使用模板生成请求体 */
|
/** 使用模板生成请求体 */
|
||||||
@@ -102,11 +139,23 @@ export interface GenerateFromTemplatePayload {
|
|||||||
voiceover_duration: number;
|
voiceover_duration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 验证警告详情 */
|
||||||
|
export interface ValidationWarningDetails {
|
||||||
|
/** 相关字段名 */
|
||||||
|
field?: string;
|
||||||
|
/** 期望值 */
|
||||||
|
expected?: string | number;
|
||||||
|
/** 实际值 */
|
||||||
|
actual?: string | number;
|
||||||
|
/** 建议值 */
|
||||||
|
suggested?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
/** 验证/生成响应 */
|
/** 验证/生成响应 */
|
||||||
export interface ValidateWarning {
|
export interface ValidateWarning {
|
||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
details?: Record<string, unknown>;
|
details?: ValidationWarningDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 使用模板生成响应 */
|
/** 使用模板生成响应 */
|
||||||
|
|||||||
+113
-14
@@ -1,8 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 成品相关 API
|
* 成品 / 视频相关 API
|
||||||
* Phase 1 重构:去掉 projectId,成品直接归属用户
|
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
|
||||||
*/
|
*/
|
||||||
import apiClient from "./client";
|
import apiClient from "./client";
|
||||||
|
import { getGenerationTaskResults } from "./editPlans";
|
||||||
|
import type { GeneratedVideo } from "./editPlans";
|
||||||
|
|
||||||
|
/** 复核状态 */
|
||||||
|
export type ReviewStatus = "pending_review" | "approved" | "rejected";
|
||||||
|
|
||||||
/** 成品条目 */
|
/** 成品条目 */
|
||||||
export interface ProductItem {
|
export interface ProductItem {
|
||||||
@@ -14,33 +19,127 @@ export interface ProductItem {
|
|||||||
file_size?: number;
|
file_size?: number;
|
||||||
resolution?: string;
|
resolution?: string;
|
||||||
status: "processing" | "completed" | "failed";
|
status: "processing" | "completed" | "failed";
|
||||||
|
/** 复核状态 */
|
||||||
|
review_status?: ReviewStatus;
|
||||||
|
/** 所属项目 ID */
|
||||||
|
project_id?: string;
|
||||||
|
/** 所属项目名称 */
|
||||||
|
project_name?: string;
|
||||||
/** 查重率(百分比) */
|
/** 查重率(百分比) */
|
||||||
duplicate_rate?: number;
|
duplicate_rate?: number;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取当前用户的所有成品 */
|
/** 列表查询参数 */
|
||||||
export const getProducts = async (): Promise<ProductItem[]> => {
|
export interface ProductListParams {
|
||||||
const response = await apiClient.get("/products");
|
page?: number;
|
||||||
return response.data.items || response.data || [];
|
page_size?: number;
|
||||||
|
project_id?: string;
|
||||||
|
review_status?: ReviewStatus | "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页响应 */
|
||||||
|
export interface ProductListResponse {
|
||||||
|
items: ProductItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量下载任务状态 */
|
||||||
|
export interface BatchDownloadStatus {
|
||||||
|
job_id: string;
|
||||||
|
status: "processing" | "completed" | "failed";
|
||||||
|
/** 完成后返回的下载 URL */
|
||||||
|
download_url?: string;
|
||||||
|
/** 进度百分比 */
|
||||||
|
progress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 generation task 数据映射为 ProductItem 格式
|
||||||
|
*/
|
||||||
|
function mapTaskToProductItem(task: GeneratedVideo): ProductItem {
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
title: task.name || "未命名视频",
|
||||||
|
video_url: task.file_url,
|
||||||
|
thumbnail_url: task.thumbnail_url,
|
||||||
|
duration_seconds: task.duration,
|
||||||
|
file_size: task.file_size,
|
||||||
|
resolution:
|
||||||
|
task.width && task.height ? `${task.width}x${task.height}` : undefined,
|
||||||
|
status:
|
||||||
|
task.status === "completed"
|
||||||
|
? "completed"
|
||||||
|
: task.status === "failed"
|
||||||
|
? "failed"
|
||||||
|
: "processing",
|
||||||
|
review_status: task.review_status as ReviewStatus | undefined,
|
||||||
|
project_id: task.project_id,
|
||||||
|
created_at: task.created_at,
|
||||||
|
updated_at: task.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取成品列表(支持分页和筛选)— 实际从 generation tasks 获取 */
|
||||||
|
export const getProducts = async (
|
||||||
|
params?: ProductListParams,
|
||||||
|
): Promise<ProductItem[]> => {
|
||||||
|
const response = await apiClient.get("/generation/tasks", { params });
|
||||||
|
const tasks = response.data.items || response.data || [];
|
||||||
|
return tasks.map(mapTaskToProductItem);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 获取单个成品详情 */
|
/** 获取单个成品详情 — 通过 task ID 获取结果 */
|
||||||
export const getProduct = async (productId: string): Promise<ProductItem> => {
|
export const getProduct = async (productId: string): Promise<ProductItem> => {
|
||||||
const response = await apiClient.get(`/products/${productId}`);
|
const response = await apiClient.get(`/generation/tasks/${productId}`);
|
||||||
return response.data;
|
return mapTaskToProductItem(response.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 删除成品 */
|
/** 删除成品 — 删除 generation task */
|
||||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||||
await apiClient.delete(`/products/${productId}`);
|
await apiClient.delete(`/generation/tasks/${productId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 获取成品下载链接 */
|
/** 获取成品下载链接 — 从 generation task results 获取 */
|
||||||
export const getProductDownloadUrl = async (
|
export const getProductDownloadUrl = async (
|
||||||
productId: string,
|
productId: string,
|
||||||
): Promise<{ url: string; expires_at: string }> => {
|
): Promise<{ url: string; expires_at: string }> => {
|
||||||
const response = await apiClient.get(`/products/${productId}/download-url`);
|
const videos = await getGenerationTaskResults(productId);
|
||||||
return response.data;
|
const video = videos[0];
|
||||||
|
if (!video?.download_url) throw new Error("下载链接不可用");
|
||||||
|
return { url: video.download_url, expires_at: "" };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
|
||||||
|
export const updateReviewStatus = async (
|
||||||
|
productId: string,
|
||||||
|
status: ReviewStatus,
|
||||||
|
): Promise<ProductItem> => {
|
||||||
|
// 后端暂无 /generation/tasks/{id}/review 端点
|
||||||
|
// 暂时返回当前状态,后续可扩展
|
||||||
|
const product = await getProduct(productId);
|
||||||
|
return { ...product, review_status: status };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 发起批量下载 — TODO: 后端暂无对应端点 */
|
||||||
|
export const batchDownload = async (
|
||||||
|
videoIds: string[],
|
||||||
|
): Promise<{ job_id: string }> => {
|
||||||
|
// 后端暂无 /generation/tasks/batch-download 端点
|
||||||
|
// 暂时返回模拟 job_id,后续可扩展
|
||||||
|
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds);
|
||||||
|
return { job_id: `mock-${Date.now()}` };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
|
||||||
|
export const getBatchDownloadStatus = async (
|
||||||
|
jobId: string,
|
||||||
|
): Promise<BatchDownloadStatus> => {
|
||||||
|
// 后端暂无 /generation/tasks/batch-download/{jobId} 端点
|
||||||
|
// 暂时返回模拟状态,后续可扩展
|
||||||
|
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId);
|
||||||
|
return { job_id: jobId, status: "processing", progress: 0 };
|
||||||
};
|
};
|
||||||
|
|||||||
+58
-12
@@ -1,31 +1,67 @@
|
|||||||
/**
|
/**
|
||||||
* 任务相关 API
|
* 任务相关 API
|
||||||
* 对接后端方案 A 扩展后的端点(PR #109)
|
* 对接后端任务中心 API:
|
||||||
* - POST /api/v1/generation/tasks — 创建生成任务(template_id + asset_ids 细粒度模式)
|
* - POST /api/v1/generation/tasks — 创建生成任务
|
||||||
* - GET /api/v1/tasks — 用户级任务列表(跨 project)
|
* - GET /api/v1/tasks — 用户级任务列表(支持分页/筛选)
|
||||||
* - POST /api/v1/tasks/{task_id}/retry — 简化重试
|
* - GET /api/v1/tasks/{task_id} — 任务详情(含 error_info)
|
||||||
|
* - POST /api/v1/tasks/{task_id}/retry — 重试失败任务
|
||||||
*/
|
*/
|
||||||
import apiClient from "./client";
|
import apiClient from "./client";
|
||||||
|
|
||||||
/* ──────────── 类型定义 ──────────── */
|
/* ──────────── 类型定义 ──────────── */
|
||||||
|
|
||||||
|
/** 任务状态 */
|
||||||
|
export type TaskStatus =
|
||||||
|
"pending" | "waiting" | "running" | "completed" | "failed" | "cancelled";
|
||||||
|
|
||||||
|
/** 任务类型 */
|
||||||
|
export type TaskType = "ingest" | "generation" | string;
|
||||||
|
|
||||||
|
/** 错误详情 */
|
||||||
|
export interface TaskErrorInfo {
|
||||||
|
error_type: string;
|
||||||
|
error_message: string;
|
||||||
|
failed_step: string;
|
||||||
|
stack_trace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** 任务条目(对应用户级 UserTaskResponse) */
|
/** 任务条目(对应用户级 UserTaskResponse) */
|
||||||
export interface TaskItem {
|
export interface TaskItem {
|
||||||
id: string;
|
id: string;
|
||||||
task_type: "ingest" | "generation" | string;
|
task_type: TaskType;
|
||||||
project_id: string;
|
project_id: string;
|
||||||
template_id: string;
|
template_id?: string;
|
||||||
status: string;
|
status: TaskStatus;
|
||||||
progress: number;
|
progress: number;
|
||||||
current_step: string;
|
current_step: string;
|
||||||
error_message: string;
|
error_message: string;
|
||||||
user_message: string;
|
user_message: string;
|
||||||
retryable: boolean;
|
retryable: boolean;
|
||||||
source_id: string;
|
source_id: string;
|
||||||
|
/** 错误详情(失败任务) */
|
||||||
|
error_info?: TaskErrorInfo;
|
||||||
|
/** 耗时(秒) */
|
||||||
|
duration_seconds?: number;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 任务列表查询参数 */
|
||||||
|
export interface TaskListParams {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
status?: TaskStatus | "all";
|
||||||
|
task_type?: TaskType | "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务列表分页响应 */
|
||||||
|
export interface TaskListResponse {
|
||||||
|
items: TaskItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** 创建生成任务请求参数 */
|
/** 创建生成任务请求参数 */
|
||||||
export interface CreateGenerationTaskRequest {
|
export interface CreateGenerationTaskRequest {
|
||||||
template_id: string;
|
template_id: string;
|
||||||
@@ -64,13 +100,23 @@ export const createGenerationTask = async (
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 获取当前用户的所有任务(跨 project) */
|
/** 获取任务列表(支持分页和筛选) */
|
||||||
export const getUserTasks = async (): Promise<TaskItem[]> => {
|
export const getTasks = async (
|
||||||
const { data } = await apiClient.get("/tasks");
|
params?: TaskListParams,
|
||||||
return data.items || [];
|
): Promise<TaskListResponse> => {
|
||||||
|
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 获取单个任务详情(用于轮询进度) */
|
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */
|
||||||
|
export const getUserTasks = async (): Promise<TaskItem[]> => {
|
||||||
|
const { data } = await apiClient.get("/tasks");
|
||||||
|
return data.items || data || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 获取单个任务详情(含 error_info) */
|
||||||
export const getTask = async (taskId: string): Promise<TaskItem> => {
|
export const getTask = async (taskId: string): Promise<TaskItem> => {
|
||||||
const { data } = await apiClient.get(`/tasks/${taskId}`);
|
const { data } = await apiClient.get(`/tasks/${taskId}`);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -1,26 +1,112 @@
|
|||||||
/**
|
/**
|
||||||
* 模板相关 API
|
* 模板相关 API
|
||||||
* Phase 1 新增:全局模板库
|
* 对接后端模板管理接口:
|
||||||
|
* - GET /api/v1/templates — 模板列表(分页/筛选)
|
||||||
|
* - GET /api/v1/templates/{id} — 模板详情
|
||||||
|
* - POST /api/v1/templates/{id}/copy — 复制模板
|
||||||
|
* - POST /api/v1/templates/{id}/generate — 从模板生成剪辑计划
|
||||||
|
* - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏
|
||||||
*/
|
*/
|
||||||
import apiClient from "./client";
|
import apiClient from "./client";
|
||||||
|
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner";
|
||||||
|
import type { EditPlanConfig } from "./editPlans";
|
||||||
|
|
||||||
/** 模板条目 */
|
/* ──────────── 类型定义 ──────────── */
|
||||||
|
|
||||||
|
/** 模板条目(后端 TemplateResponse) */
|
||||||
export interface TemplateItem {
|
export interface TemplateItem {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
tags?: string[];
|
||||||
target_duration: number;
|
target_duration: number;
|
||||||
clip_count: number;
|
clip_count: number;
|
||||||
|
/** 使用次数 */
|
||||||
|
usage_count?: number;
|
||||||
thumbnail_url?: string;
|
thumbnail_url?: string;
|
||||||
preview_url?: string;
|
preview_url?: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
is_favorite?: boolean;
|
is_favorite?: boolean;
|
||||||
|
/** 素材规则(片段配置) */
|
||||||
|
segments?: TemplateSegment[];
|
||||||
|
/** 字幕样式 */
|
||||||
|
subtitle_config?: SubtitleConfig;
|
||||||
|
/** BGM 配置 */
|
||||||
|
bgm_config?: BgmConfig;
|
||||||
|
/** 标题配置 */
|
||||||
|
title_config?: TitleConfig;
|
||||||
|
/** 视频比例 */
|
||||||
|
aspect_ratio?: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取全局模板列表 */
|
/** 模板片段(素材规则) */
|
||||||
export const getTemplates = async (): Promise<TemplateItem[]> => {
|
export interface TemplateSegment {
|
||||||
|
id?: string;
|
||||||
|
segment_order: number;
|
||||||
|
duration_min: number;
|
||||||
|
duration_max: number;
|
||||||
|
material_type: string | null;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模板列表查询参数 */
|
||||||
|
export interface TemplateListParams {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
category?: string;
|
||||||
|
tags?: string;
|
||||||
|
keyword?: string;
|
||||||
|
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
|
||||||
|
duration_range?: "short" | "medium" | "long";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模板列表分页响应 */
|
||||||
|
export interface TemplateListResponse {
|
||||||
|
items: TemplateItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从模板生成剪辑计划请求 */
|
||||||
|
export interface GenerateFromTemplateRequest {
|
||||||
|
asset_ids?: string[];
|
||||||
|
name?: string;
|
||||||
|
config?: EditPlanConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从模板生成剪辑计划响应 */
|
||||||
|
export interface GenerateFromTemplateResponse {
|
||||||
|
plan_id: string;
|
||||||
|
template_id: string;
|
||||||
|
status: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复制模板响应 */
|
||||||
|
export interface CopyTemplateResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
source_template_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────── API 函数 ──────────── */
|
||||||
|
|
||||||
|
/** 获取模板列表(支持分页和筛选) */
|
||||||
|
export const getTemplates = async (
|
||||||
|
params?: TemplateListParams,
|
||||||
|
): Promise<TemplateListResponse> => {
|
||||||
|
const { data } = await apiClient.get<TemplateListResponse>("/templates", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 获取模板列表(兼容旧接口,返回数组) */
|
||||||
|
export const getTemplatesList = async (): Promise<TemplateItem[]> => {
|
||||||
const response = await apiClient.get("/templates");
|
const response = await apiClient.get("/templates");
|
||||||
return response.data.items || response.data || [];
|
return response.data.items || response.data || [];
|
||||||
};
|
};
|
||||||
@@ -42,3 +128,46 @@ export const toggleFavoriteTemplate = async (
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 复制模板(创建副本到我的模板) */
|
||||||
|
export const copyTemplate = async (
|
||||||
|
templateId: string,
|
||||||
|
): Promise<CopyTemplateResponse> => {
|
||||||
|
const response = await apiClient.post<CopyTemplateResponse>(
|
||||||
|
`/templates/${templateId}/copy`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 从模板生成剪辑计划 */
|
||||||
|
export const generateFromTemplate = async (
|
||||||
|
templateId: string,
|
||||||
|
data?: GenerateFromTemplateRequest,
|
||||||
|
): Promise<GenerateFromTemplateResponse> => {
|
||||||
|
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||||
|
`/templates/${templateId}/generate`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ──────────── 常量 ──────────── */
|
||||||
|
|
||||||
|
/** 模板分类选项 */
|
||||||
|
export const TEMPLATE_CATEGORY_OPTIONS = [
|
||||||
|
{ value: "", label: "全部分类" },
|
||||||
|
{ value: "口播", label: "口播" },
|
||||||
|
{ value: "种草", label: "种草" },
|
||||||
|
{ value: "产品", label: "产品" },
|
||||||
|
{ value: "品牌", label: "品牌" },
|
||||||
|
{ value: "混剪", label: "混剪" },
|
||||||
|
{ value: "Vlog", label: "Vlog" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 时长筛选选项 */
|
||||||
|
export const TEMPLATE_DURATION_OPTIONS = [
|
||||||
|
{ value: "", label: "全部时长" },
|
||||||
|
{ value: "short", label: "30秒以内" },
|
||||||
|
{ value: "medium", label: "30秒-2分钟" },
|
||||||
|
{ value: "long", label: "2分钟以上" },
|
||||||
|
];
|
||||||
|
|||||||
+63
-2
@@ -8,6 +8,18 @@ import apiClient from "./client";
|
|||||||
|
|
||||||
/* ── 类型定义 ──────────────────────────────────── */
|
/* ── 类型定义 ──────────────────────────────────── */
|
||||||
|
|
||||||
|
/** TTS 元数据(合成时附带的扩展信息) */
|
||||||
|
export interface TTSMetadata {
|
||||||
|
/** 语音时长(秒) */
|
||||||
|
duration?: number;
|
||||||
|
/** 采样率(Hz) */
|
||||||
|
sample_rate?: number;
|
||||||
|
/** 语言 */
|
||||||
|
language?: string;
|
||||||
|
/** 其他扩展字段 */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
/** TTS 合成请求参数 */
|
/** TTS 合成请求参数 */
|
||||||
export interface TTSSynthesizeRequest {
|
export interface TTSSynthesizeRequest {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -18,7 +30,7 @@ export interface TTSSynthesizeRequest {
|
|||||||
voice_model?: string;
|
voice_model?: string;
|
||||||
voice_clone_profile_id?: string;
|
voice_clone_profile_id?: string;
|
||||||
format?: string;
|
format?: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: TTSMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** TTS 合成创建响应 */
|
/** TTS 合成创建响应 */
|
||||||
@@ -49,7 +61,7 @@ export interface TTSJob {
|
|||||||
error_message: string | null;
|
error_message: string | null;
|
||||||
retry_count: number;
|
retry_count: number;
|
||||||
max_retries: number;
|
max_retries: number;
|
||||||
metadata_: Record<string, unknown> | null;
|
metadata_: TTSMetadata | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -140,3 +152,52 @@ export const saveTtsToLibrary = async (
|
|||||||
export const deleteTTSJob = async (jobId: string): Promise<void> => {
|
export const deleteTTSJob = async (jobId: string): Promise<void> => {
|
||||||
await apiClient.delete(`/tts/jobs/${jobId}`);
|
await apiClient.delete(`/tts/jobs/${jobId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── 音色列表 ──────────────────────────────────── */
|
||||||
|
|
||||||
|
/** TTS 音色 */
|
||||||
|
export interface TTSVoice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** 音色分类标签:male/female/young/service/news/emotion */
|
||||||
|
category?: string;
|
||||||
|
/** 语言 */
|
||||||
|
language?: string;
|
||||||
|
/** 试听 URL */
|
||||||
|
preview_url?: string;
|
||||||
|
/** 描述 */
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 TTS 音色列表 */
|
||||||
|
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
|
||||||
|
const response = await apiClient.get<TTSVoice[]>("/tts/voices");
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── TTS 试听 ──────────────────────────────────── */
|
||||||
|
|
||||||
|
/** TTS 试听请求参数 */
|
||||||
|
export interface TTSPreviewRequest {
|
||||||
|
text: string;
|
||||||
|
voice_id: string;
|
||||||
|
speed?: number;
|
||||||
|
pitch?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TTS 试听响应 */
|
||||||
|
export interface TTSPreviewResponse {
|
||||||
|
audio_url: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TTS 试听 */
|
||||||
|
export const previewTts = async (
|
||||||
|
data: TTSPreviewRequest,
|
||||||
|
): Promise<TTSPreviewResponse> => {
|
||||||
|
const response = await apiClient.post<TTSPreviewResponse>(
|
||||||
|
"/tts/preview",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ export interface CreateVoiceCloneRequest {
|
|||||||
|
|
||||||
/* ── 后端 API 类型 ────────────────────────────────────── */
|
/* ── 后端 API 类型 ────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** 音色克隆元数据(克隆时附带的扩展信息) */
|
||||||
|
export interface VoiceCloneMetadata {
|
||||||
|
/** 语音时长(秒) */
|
||||||
|
duration?: number;
|
||||||
|
/** 采样率(Hz) */
|
||||||
|
sample_rate?: number;
|
||||||
|
/** 音色 ID(克隆完成后分配) */
|
||||||
|
voice_id?: string;
|
||||||
|
/** 其他扩展字段 */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
/** 后端克隆档案响应 */
|
/** 后端克隆档案响应 */
|
||||||
export interface VoiceCloneProfile {
|
export interface VoiceCloneProfile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -51,7 +63,7 @@ export interface VoiceCloneProfile {
|
|||||||
error_message: string | null;
|
error_message: string | null;
|
||||||
retry_count: number;
|
retry_count: number;
|
||||||
max_retries: number;
|
max_retries: number;
|
||||||
metadata_: Record<string, unknown> | null;
|
metadata_: VoiceCloneMetadata | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -80,7 +92,7 @@ export interface CreateVoiceCloneRequestFull {
|
|||||||
language?: string;
|
language?: string;
|
||||||
gender?: string;
|
gender?: string;
|
||||||
max_retries?: number;
|
max_retries?: number;
|
||||||
metadata_?: Record<string, unknown>;
|
metadata_?: VoiceCloneMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 辅助函数 ─────────────────────────────────────────── */
|
/* ── 辅助函数 ─────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -1,365 +0,0 @@
|
|||||||
/* V21 业务组件统一样式 */
|
|
||||||
|
|
||||||
/* ==================== 按钮 ==================== */
|
|
||||||
.xx-primary-btn {
|
|
||||||
background: var(--gradient-primary) !important;
|
|
||||||
color: var(--text-inverse) !important;
|
|
||||||
border: none !important;
|
|
||||||
border-radius: var(--radius-md) !important;
|
|
||||||
padding: 10px 20px !important;
|
|
||||||
font-weight: var(--font-weight-bold) !important;
|
|
||||||
box-shadow: var(--shadow-primary) !important;
|
|
||||||
transition: var(--transition-all) !important;
|
|
||||||
cursor: pointer;
|
|
||||||
height: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-primary-btn:hover {
|
|
||||||
box-shadow: var(--shadow-hover) !important;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-ghost-btn {
|
|
||||||
background: transparent !important;
|
|
||||||
color: var(--primary-color) !important;
|
|
||||||
border: 2px solid var(--primary-color) !important;
|
|
||||||
border-radius: var(--radius-md) !important;
|
|
||||||
padding: var(--space-sm) 18px !important;
|
|
||||||
font-weight: var(--font-weight-bold) !important;
|
|
||||||
transition: var(--transition-all) !important;
|
|
||||||
cursor: pointer;
|
|
||||||
height: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-ghost-btn:hover {
|
|
||||||
background: var(--primary-soft) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== 卡片 ==================== */
|
|
||||||
.xx-card {
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
padding: var(--space-lg);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
transition: all var(--transition-slow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-card:hover {
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== 页面结构 ==================== */
|
|
||||||
.xx-page {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-page-head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 18px;
|
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-page-head h2 {
|
|
||||||
font-size: 26px;
|
|
||||||
font-weight: var(--font-weight-extrabold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0 0 var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-page-head p {
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== 表格样式 ==================== */
|
|
||||||
.xx-table-card {
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
padding: 20px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表格包装器 */
|
|
||||||
.xx-table-wrapper {
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== 标签/Tag ==================== */
|
|
||||||
.xx-tag {
|
|
||||||
padding: var(--space-xs) 12px;
|
|
||||||
border-radius: var(--radius-xs);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-tag-indigo {
|
|
||||||
background: var(--primary-soft);
|
|
||||||
color: var(--primary-color);
|
|
||||||
border: 1px solid var(--color-primary-200);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-tag-success {
|
|
||||||
background: var(--success-soft);
|
|
||||||
color: var(--color-secondary-500);
|
|
||||||
border: 1px solid var(--success-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-tag-warning {
|
|
||||||
background: var(--warning-soft);
|
|
||||||
color: var(--accent-dark);
|
|
||||||
border: 1px solid var(--color-accent-200);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-tag-error {
|
|
||||||
background: var(--error-soft);
|
|
||||||
color: var(--error-color);
|
|
||||||
border: 1px solid var(--error-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== 搜索栏 ==================== */
|
|
||||||
.xx-search-bar {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-search-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px 18px;
|
|
||||||
border: 2px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
background: var(--bg-primary);
|
|
||||||
transition: var(--transition-all);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-search-input:focus {
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
box-shadow: 0 0 0 4px
|
|
||||||
color-mix(in srgb, var(--primary-color) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== Modal ==================== */
|
|
||||||
.xx-modal .ant-modal-content {
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
padding: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-modal .ant-modal-header {
|
|
||||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
|
||||||
padding: 20px var(--space-lg);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-modal .ant-modal-title {
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-modal .ant-modal-footer {
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
padding: var(--space-md) var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== 空状态 ==================== */
|
|
||||||
.xx-empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--space-3xl) var(--space-lg);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-empty-state-icon {
|
|
||||||
font-size: 48px;
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== 网格布局 ==================== */
|
|
||||||
.xx-grid-2 {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-grid-3 {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-grid-4 {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.xx-grid-2,
|
|
||||||
.xx-grid-3,
|
|
||||||
.xx-grid-4 {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== 配额展示 ==================== */
|
|
||||||
.xx-quota-item {
|
|
||||||
padding: 20px;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
transition: var(--transition-all);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xx-quota-item:hover {
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
box-shadow: 0 8px 24px
|
|
||||||
color-mix(in srgb, var(--primary-color) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== 进度条 ==================== */
|
|
||||||
.xx-progress {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== Ant Design 覆盖样式 ==================== */
|
|
||||||
/* Table overrides */
|
|
||||||
.ant-table-wrapper .ant-table-thead > tr > th {
|
|
||||||
background: var(--bg-secondary) !important;
|
|
||||||
font-weight: var(--font-weight-bold) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
border-bottom: 2px solid var(--border-color) !important;
|
|
||||||
padding: 14px var(--space-md) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-table-wrapper .ant-table-tbody > tr > td {
|
|
||||||
padding: 14px var(--space-md) !important;
|
|
||||||
border-bottom: 1px solid var(--color-gray-100) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-table-wrapper .ant-table-tbody > tr:hover > td {
|
|
||||||
background: var(--color-gray-50) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card overrides */
|
|
||||||
.ant-card {
|
|
||||||
border-radius: var(--radius-xl) !important;
|
|
||||||
border: 1px solid var(--border-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-card-head {
|
|
||||||
border-bottom: 1px solid var(--border-color) !important;
|
|
||||||
min-height: 52px !important;
|
|
||||||
padding: 0 var(--space-lg) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-card-head-title {
|
|
||||||
font-weight: var(--font-weight-bold) !important;
|
|
||||||
font-size: var(--font-size-md) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-card-body {
|
|
||||||
padding: 20px var(--space-lg) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal overrides */
|
|
||||||
.ant-modal-content {
|
|
||||||
border-radius: var(--radius-xl) !important;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-modal-header {
|
|
||||||
padding: 20px var(--space-lg) !important;
|
|
||||||
background: var(--bg-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-modal-title {
|
|
||||||
font-weight: var(--font-weight-bold) !important;
|
|
||||||
font-size: var(--font-size-lg) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-modal-body {
|
|
||||||
padding: var(--space-lg) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-modal-footer {
|
|
||||||
padding: var(--space-md) var(--space-lg) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Button overrides */
|
|
||||||
.ant-btn-primary {
|
|
||||||
background: var(--gradient-primary) !important;
|
|
||||||
border: none !important;
|
|
||||||
border-radius: var(--radius-md) !important;
|
|
||||||
box-shadow: var(--shadow-primary) !important;
|
|
||||||
height: auto !important;
|
|
||||||
padding: 10px 20px !important;
|
|
||||||
font-weight: var(--font-weight-bold) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-btn-primary:hover {
|
|
||||||
background: var(--gradient-primary) !important;
|
|
||||||
box-shadow: var(--shadow-hover) !important;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tag overrides */
|
|
||||||
.ant-tag {
|
|
||||||
border-radius: var(--radius-xs) !important;
|
|
||||||
padding: var(--space-xs) 12px !important;
|
|
||||||
font-weight: var(--font-weight-medium) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Select overrides */
|
|
||||||
.ant-select-selector {
|
|
||||||
border-radius: var(--radius-md) !important;
|
|
||||||
border-color: var(--border-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-select:not(.ant-select-disabled):hover .ant-select-selector {
|
|
||||||
border-color: var(--primary-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-select-focused .ant-select-selector {
|
|
||||||
border-color: var(--primary-color) !important;
|
|
||||||
box-shadow: 0 0 0 3px
|
|
||||||
color-mix(in srgb, var(--primary-color) 10%, transparent) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Input overrides */
|
|
||||||
.ant-input {
|
|
||||||
border-radius: var(--radius-md) !important;
|
|
||||||
border-color: var(--border-color) !important;
|
|
||||||
padding: 10px 14px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-input:hover {
|
|
||||||
border-color: var(--primary-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-input:focus {
|
|
||||||
border-color: var(--primary-color) !important;
|
|
||||||
box-shadow: 0 0 0 3px
|
|
||||||
color-mix(in srgb, var(--primary-color) 10%, transparent) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress overrides */
|
|
||||||
.ant-progress-inner {
|
|
||||||
background: var(--color-gray-100) !important;
|
|
||||||
border-radius: var(--radius-xs) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-progress-bg {
|
|
||||||
border-radius: var(--radius-xs) !important;
|
|
||||||
}
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
/**
|
|
||||||
* CloneVoiceModal — 音色克隆弹窗
|
|
||||||
*
|
|
||||||
* 三步骤状态:input → uploading → success
|
|
||||||
* 支持上传音频文件或直接录制(mock,无真实录音)
|
|
||||||
*
|
|
||||||
* V21 Design System — 零 antd 直接导入
|
|
||||||
*/
|
|
||||||
import React, { useState, useCallback, useRef } from "react";
|
|
||||||
import { Modal, Button } from "@/components/ui";
|
|
||||||
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
|
|
||||||
import type { VoiceClone } from "@/api/voiceClone";
|
|
||||||
import { uploadAsset } from "@/api/assets";
|
|
||||||
import "./clone-voice-modal.css";
|
|
||||||
|
|
||||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
type ModalStep = "input" | "uploading" | "success";
|
|
||||||
|
|
||||||
export interface CloneVoiceModalProps {
|
|
||||||
/** 弹窗是否可见 */
|
|
||||||
open: boolean;
|
|
||||||
/** 关闭弹窗回调 */
|
|
||||||
onClose: () => void;
|
|
||||||
/** 克隆成功回调(返回新创建的音色) */
|
|
||||||
onSuccess?: (voice: VoiceClone) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 默认音色名称计数器 ─────────────────────────────────── */
|
|
||||||
|
|
||||||
let cloneCounter = 1;
|
|
||||||
|
|
||||||
const getNextDefaultName = (): string => {
|
|
||||||
const name = `我的声音 ${cloneCounter}`;
|
|
||||||
cloneCounter += 1;
|
|
||||||
return name;
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ── 组件 ───────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
onSuccess,
|
|
||||||
}) => {
|
|
||||||
const [step, setStep] = useState<ModalStep>("input");
|
|
||||||
const [voiceName, setVoiceName] = useState("");
|
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
||||||
const [dragActive, setDragActive] = useState(false);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
/** 重置弹窗状态 */
|
|
||||||
const resetState = useCallback(() => {
|
|
||||||
setStep("input");
|
|
||||||
setVoiceName("");
|
|
||||||
setSelectedFile(null);
|
|
||||||
setIsRecording(false);
|
|
||||||
setDragActive(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/** 关闭弹窗 */
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
resetState();
|
|
||||||
onClose();
|
|
||||||
}, [resetState, onClose]);
|
|
||||||
|
|
||||||
/** 上传区域点击 */
|
|
||||||
const handleUploadClick = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 文件选择 */
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
setSelectedFile(file);
|
|
||||||
// 清除之前的录制状态
|
|
||||||
setIsRecording(false);
|
|
||||||
}
|
|
||||||
// 清空 input 以允许重复选择同一文件
|
|
||||||
e.target.value = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 拖拽事件 */
|
|
||||||
const handleDrag = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.type === "dragenter" || e.type === "dragover") {
|
|
||||||
setDragActive(true);
|
|
||||||
} else if (e.type === "dragleave") {
|
|
||||||
setDragActive(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setDragActive(false);
|
|
||||||
const file = e.dataTransfer.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
const ext = file.name.split(".").pop()?.toLowerCase();
|
|
||||||
if (ext === "mp3" || ext === "wav") {
|
|
||||||
setSelectedFile(file);
|
|
||||||
setIsRecording(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 录制按钮(mock) */
|
|
||||||
const handleRecord = () => {
|
|
||||||
setIsRecording((prev) => !prev);
|
|
||||||
if (!isRecording) {
|
|
||||||
// 开始录制 — 清除已选文件
|
|
||||||
setSelectedFile(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 开始克隆 */
|
|
||||||
const handleStartClone = async () => {
|
|
||||||
const name = voiceName.trim() || getNextDefaultName();
|
|
||||||
setStep("uploading");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 先上传音频文件获取真实 URL
|
|
||||||
let audioUrl: string;
|
|
||||||
if (selectedFile) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", selectedFile);
|
|
||||||
formData.append("kind", "voice");
|
|
||||||
const uploadResult = await uploadAsset(formData);
|
|
||||||
audioUrl = uploadResult.url;
|
|
||||||
} else {
|
|
||||||
// 录制功能暂未实现,提示用户上传
|
|
||||||
setStep("input");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提交克隆请求
|
|
||||||
const result = await createVoiceClone({
|
|
||||||
name,
|
|
||||||
audio_url: audioUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
setStep("success");
|
|
||||||
|
|
||||||
// 2秒后自动关闭
|
|
||||||
setTimeout(() => {
|
|
||||||
onSuccess?.(toVoiceClone(result));
|
|
||||||
handleClose();
|
|
||||||
}, 2000);
|
|
||||||
} catch {
|
|
||||||
setStep("input");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 弹窗打开时初始化默认名称 */
|
|
||||||
const handleAfterOpenChange = (visible: boolean) => {
|
|
||||||
if (visible) {
|
|
||||||
setVoiceName(getNextDefaultName());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const canStart = selectedFile || isRecording;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
onCancel={handleClose}
|
|
||||||
title="🎤 克隆新音色"
|
|
||||||
width={520}
|
|
||||||
footer={null}
|
|
||||||
destroyOnClose
|
|
||||||
afterOpenChange={handleAfterOpenChange}
|
|
||||||
>
|
|
||||||
{/* ── 输入步骤 ──────────────────────────────────── */}
|
|
||||||
{step === "input" && (
|
|
||||||
<div className="cvm-body">
|
|
||||||
{/* 音色名称 */}
|
|
||||||
<div className="cvm-field">
|
|
||||||
<label className="cvm-label">音色名称</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="cvm-input"
|
|
||||||
value={voiceName}
|
|
||||||
onChange={(e) => setVoiceName(e.target.value)}
|
|
||||||
placeholder="输入音色名称"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 上传区域 */}
|
|
||||||
<div className="cvm-field">
|
|
||||||
<label className="cvm-label">上传音频</label>
|
|
||||||
<div
|
|
||||||
className={`cvm-upload-zone${dragActive ? " cvm-upload-zone--active" : ""}`}
|
|
||||||
onClick={handleUploadClick}
|
|
||||||
onDragEnter={handleDrag}
|
|
||||||
onDragOver={handleDrag}
|
|
||||||
onDragLeave={handleDrag}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
<div className="cvm-upload-icon">🎵</div>
|
|
||||||
<p className="cvm-upload-title">
|
|
||||||
{selectedFile ? selectedFile.name : "拖拽音频文件到此处"}
|
|
||||||
</p>
|
|
||||||
<p className="cvm-upload-hint">支持 MP3、WAV 格式</p>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".mp3,.wav,audio/mpeg,audio/wav"
|
|
||||||
style={{ display: "none" }}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 或分隔 */}
|
|
||||||
<div className="cvm-divider">
|
|
||||||
<div className="cvm-divider-line" />
|
|
||||||
<span className="cvm-divider-text">或</span>
|
|
||||||
<div className="cvm-divider-line" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 录制区域 */}
|
|
||||||
<div className="cvm-field">
|
|
||||||
<label className="cvm-label">直接录制</label>
|
|
||||||
<div className="cvm-record-area">
|
|
||||||
<p className="cvm-record-hint">
|
|
||||||
{isRecording
|
|
||||||
? "录制中…再次点击停止"
|
|
||||||
: "点击按钮开始录制你的声音"}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`cvm-record-btn${isRecording ? " cvm-record-btn--recording" : ""}`}
|
|
||||||
onClick={handleRecord}
|
|
||||||
>
|
|
||||||
🎙️
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 提示 */}
|
|
||||||
<div className="cvm-tip">
|
|
||||||
<span className="cvm-tip-icon">💡</span>
|
|
||||||
<span>
|
|
||||||
建议上传10秒~3分钟的清晰语音,环境安静、语速均匀效果最佳
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 底部按钮 */}
|
|
||||||
<div className="cvm-footer">
|
|
||||||
<Button buttonType="ghost" onClick={handleClose}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
buttonType="primary"
|
|
||||||
disabled={!canStart}
|
|
||||||
onClick={handleStartClone}
|
|
||||||
>
|
|
||||||
🎤 开始克隆
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── 上传中步骤 ────────────────────────────────── */}
|
|
||||||
{step === "uploading" && (
|
|
||||||
<div className="cvm-uploading">
|
|
||||||
<div className="cvm-uploading-spinner" />
|
|
||||||
<p className="cvm-uploading-text">正在克隆你的音色…</p>
|
|
||||||
<p className="cvm-uploading-sub">AI 正在分析你的声音特征,请稍候</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── 成功步骤 ──────────────────────────────────── */}
|
|
||||||
{step === "success" && (
|
|
||||||
<div className="cvm-success">
|
|
||||||
<div className="cvm-success-icon">✅</div>
|
|
||||||
<h3 className="cvm-success-title">克隆已提交</h3>
|
|
||||||
<p className="cvm-success-desc">
|
|
||||||
音色正在生成中,完成后将出现在列表中
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CloneVoiceModal;
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
/**
|
|
||||||
* CloneVoiceModal — V21 Design System
|
|
||||||
*
|
|
||||||
* 音色克隆弹窗样式
|
|
||||||
* 三步骤状态:input → uploading → success
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* ── 弹窗内容区 ─────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.cvm-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 表单区 ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.cvm-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-label {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary, #475467);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border: 1px solid var(--line, #e4e7ec);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--bg-surface, #fff);
|
|
||||||
color: var(--text-primary, #101828);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
transition:
|
|
||||||
border-color 0.2s,
|
|
||||||
box-shadow 0.2s;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-input:focus {
|
|
||||||
border-color: var(--primary, #6366f1);
|
|
||||||
box-shadow: 0 0 0 3px
|
|
||||||
color-mix(in srgb, var(--primary-color) 12%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-input::placeholder {
|
|
||||||
color: var(--muted, #98a2b3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 上传区域 ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.cvm-upload-zone {
|
|
||||||
border: 2px dashed var(--line, #e4e7ec);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 28px 20px;
|
|
||||||
text-align: center;
|
|
||||||
background: var(--bg-subtle, #f8fafc);
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
border-color 0.2s,
|
|
||||||
background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-upload-zone:hover {
|
|
||||||
border-color: var(--primary, #6366f1);
|
|
||||||
background: color-mix(in srgb, var(--primary-color) 4%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-upload-zone.cvm-upload-zone--active {
|
|
||||||
border-color: var(--primary, #6366f1);
|
|
||||||
background: color-mix(in srgb, var(--primary-color) 6%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-upload-icon {
|
|
||||||
font-size: 36px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-upload-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #101828);
|
|
||||||
margin: 0 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-upload-hint {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--muted, #98a2b3);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 或分隔线 ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.cvm-divider {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-divider-line {
|
|
||||||
flex: 1;
|
|
||||||
height: 1px;
|
|
||||||
background: var(--line, #e4e7ec);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-divider-text {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--muted, #98a2b3);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 录制区域 ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.cvm-record-area {
|
|
||||||
border: 1px solid var(--line, #e4e7ec);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 24px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-record-hint {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--muted, #98a2b3);
|
|
||||||
margin: 0 0 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-record-btn {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 32px;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0;
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
var(--error-color, #ef4444),
|
|
||||||
var(--error-dark, #dc2626)
|
|
||||||
);
|
|
||||||
color: var(--text-inverse);
|
|
||||||
box-shadow: 0 4px 14px
|
|
||||||
color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent);
|
|
||||||
transition:
|
|
||||||
transform 0.15s,
|
|
||||||
box-shadow 0.15s;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-record-btn:hover {
|
|
||||||
transform: scale(1.06);
|
|
||||||
box-shadow: 0 6px 20px
|
|
||||||
color-mix(in srgb, var(--error-color, #ef4444) 45%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-record-btn:active {
|
|
||||||
transform: scale(0.96);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-record-btn--recording {
|
|
||||||
animation: cvm-pulse 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes cvm-pulse {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 4px 14px
|
|
||||||
color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow: 0 4px 28px
|
|
||||||
color-mix(in srgb, var(--error-color, #ef4444) 60%, transparent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 提示条 ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.cvm-tip {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: var(--warning-soft, #fef3c7);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--warning-color, #92400e);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-tip-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 底部按钮 ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.cvm-footer {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-footer .xx-btn {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 上传中状态 ─────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.cvm-uploading {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 48px 20px;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-uploading-spinner {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border: 3px solid var(--line, #e4e7ec);
|
|
||||||
border-top-color: var(--primary, #6366f1);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: cvm-spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes cvm-spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-uploading-text {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary, #101828);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-uploading-sub {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--muted, #98a2b3);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 成功状态 ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.cvm-success {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 48px 20px;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-success-icon {
|
|
||||||
font-size: 56px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-success-title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary, #101828);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-success-desc {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--muted, #98a2b3);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 响应式 ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.cvm-overlay {
|
|
||||||
padding: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-modal {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
padding: var(--space-lg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
.cvm-upload-zone {
|
|
||||||
padding: 20px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-record-btn {
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
font-size: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-footer {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.cvm-record-btn {
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cvm-tip {
|
|
||||||
font-size: 12px;
|
|
||||||
padding: var(--space-sm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* V21 Form 表单
|
|
||||||
* 封装 Ant Design Form,应用 V21 设计系统样式
|
|
||||||
*/
|
|
||||||
import React from "react";
|
|
||||||
import { Form as AntForm } from "antd";
|
|
||||||
import type { FormProps as AntFormProps } from "antd";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import "./ui.css";
|
|
||||||
|
|
||||||
export interface FormProps extends AntFormProps {
|
|
||||||
/** 紧凑模式(减小表单项间距) */
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Form = ({ className, compact, children, ...rest }: FormProps) => {
|
|
||||||
const v21Class = classNames(
|
|
||||||
"xx-form",
|
|
||||||
compact && "xx-form-compact",
|
|
||||||
className,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<AntForm
|
|
||||||
className={v21Class}
|
|
||||||
{...(rest as Omit<FormProps, "className" | "compact" | "children">)}
|
|
||||||
>
|
|
||||||
{children as React.ReactNode}
|
|
||||||
</AntForm>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 导出 Form 的子组件(保持 antd API 一致) */
|
|
||||||
export const FormItem = AntForm.Item;
|
|
||||||
export const FormList = AntForm.List;
|
|
||||||
export const FormProvider = AntForm.Provider;
|
|
||||||
|
|
||||||
export default Form;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* V21 Pagination 分页
|
|
||||||
* 封装 Ant Design Pagination,应用 V21 设计系统样式
|
|
||||||
*/
|
|
||||||
import React from "react";
|
|
||||||
import { Pagination as AntPagination } from "antd";
|
|
||||||
import type { PaginationProps as AntPaginationProps } from "antd";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import "./ui.css";
|
|
||||||
|
|
||||||
export interface PaginationProps extends AntPaginationProps {
|
|
||||||
/** 使用 V21 样式 */
|
|
||||||
v21?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Pagination: React.FC<PaginationProps> = ({
|
|
||||||
className,
|
|
||||||
v21 = true,
|
|
||||||
...rest
|
|
||||||
}) => {
|
|
||||||
const v21Class = classNames(v21 && "xx-pagination", className);
|
|
||||||
return <AntPagination className={v21Class} {...rest} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Pagination;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
/**
|
|
||||||
* V21 Table 表格
|
|
||||||
* 封装 Ant Design Table,应用 V21 设计系统样式
|
|
||||||
*/
|
|
||||||
import React from "react";
|
|
||||||
import { Table as AntTable } from "antd";
|
|
||||||
import type { TableProps as AntTableProps } from "antd";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import "./ui.css";
|
|
||||||
|
|
||||||
export interface TableProps<
|
|
||||||
RecordType = unknown,
|
|
||||||
> extends AntTableProps<RecordType> {
|
|
||||||
/** 使用 V21 样式 */
|
|
||||||
v21?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Table<RecordType extends object = Record<string, unknown>>({
|
|
||||||
className,
|
|
||||||
v21 = true,
|
|
||||||
...rest
|
|
||||||
}: TableProps<RecordType>) {
|
|
||||||
const v21Class = classNames(v21 && "xx-table", className);
|
|
||||||
return <AntTable<RecordType> className={v21Class} {...rest} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Table as <RecordType extends object = Record<string, unknown>>(
|
|
||||||
props: TableProps<RecordType> & React.RefAttributes<HTMLDivElement>,
|
|
||||||
) => React.ReactElement;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user