feat(phase7): wire asset upload and ingest flow

This commit is contained in:
Xiaoxia AI
2026-06-17 18:14:35 +08:00
parent fe8786f5fa
commit 8aeb46aded
8 changed files with 380 additions and 80 deletions
+20
View File
@@ -9,6 +9,26 @@ from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
router = APIRouter()
@router.get("/{job_id}", response_model=IngestJobResponse)
def get_ingest_job(
job_id: str,
ingest_job_repository: SQLAlchemyIngestJobRepository = Depends(get_ingest_job_repository),
) -> IngestJobResponse:
job = ingest_job_repository.get(job_id)
if job is None:
raise ValueError(f"IngestJob {job_id} not found")
return IngestJobResponse(
id=job.id,
workspace_id=job.workspace_id,
project_id=job.project_id,
library_id=job.library_id,
storage_key=job.storage_key,
status=job.status.value,
error_message=job.error_message,
result_asset_id=job.result_asset_id,
)
@router.post("", response_model=IngestJobResponse)
def submit_ingest_job(
request: SubmitIngestJobRequest,
+4 -48
View File
@@ -1,9 +1,9 @@
from fastapi import APIRouter, Depends, UploadFile, File, Form
from fastapi import APIRouter, Depends, File, Form, UploadFile
from uuid import uuid4
from app.core.celery_app import celery_app
from app.dependencies import get_ingest_job_repository
from app.core.storage import MinIOService, get_minio_service
from app.dependencies import get_ingest_job_repository
from app.schemas.upload import UploadAssetResponse
from packages.adapters.sqlalchemy_impl import SQLAlchemyIngestJobRepository
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
@@ -16,64 +16,20 @@ async def upload_asset(
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
workspace_id: str = Form(..., description="工作空间 ID"),
project_id: str = Form(..., description="项目 ID"),
library_id: str = Form(..., description="资产库 ID"),
library_id: str = Form(..., description="素材库 ID"),
ingest_job_repository: SQLAlchemyIngestJobRepository = Depends(get_ingest_job_repository),
storage_service: MinIOService = Depends(get_minio_service),
) -> UploadAssetResponse:
"""
上传素材文件并触发导入流水线。
## 功能说明
1. **接收文件**:支持 multipart/form-data 上传
2. **存储到 MinIO**:自动存储到对象存储
3. **生成存储键**:格式为 `uploads/{id}/{filename}`
4. **提交导入任务**:创建 IngestJob 记录
5. **异步处理**:通过 Celery 队列处理
## 支持的文件类型
- **视频**MP4, MOV, AVI, MKV 等
- **音频**MP3, WAV, AAC 等
- **图片**JPG, PNG, GIF, WebP 等
## 请求示例
```bash
curl -X POST "http://localhost:8000/api/v1/upload" \
-H "Content-Type: multipart/form-data" \
-F "file=@/path/to/video.mp4" \
-F "workspace_id=ws_123" \
-F "project_id=proj_456" \
-F "library_id=lib_789"
```
## 响应说明
- `storage_key`: 文件在 MinIO 中的存储路径
- `ingest_job_id`: 导入任务 ID,用于追踪处理状态
- `url`: 文件的公开访问 URL
## 后续流程
上传成功后,系统会:
1. 自动提取文件元数据(时长、分辨率等)
2. 生成缩略图
3. 进行场景分割(视频)
4. 创建 Asset 记录
"""
# Generate storage key
"""上传素材文件并触发导入流水线。"""
file_id = uuid4().hex[:8]
storage_key = f"uploads/{file_id}/{file.filename}"
# Upload file to MinIO
file_url = storage_service.upload_file(
file.file,
storage_key,
content_type=file.content_type or "application/octet-stream",
)
# Submit ingest job
use_case = SubmitIngestJobUseCase(ingest_job_repository)
job = use_case.execute(
SubmitIngestJobCommand(
+70
View File
@@ -0,0 +1,70 @@
/**
* 素材相关 API
*/
import apiClient from './client';
export interface AssetItem {
id: string;
workspace_id: string;
project_id: string;
library_id: string;
name: string;
storage_key: string;
mime_type: string;
metadata: Record<string, unknown>;
}
export interface AssetLibraryItem {
id: string;
workspace_id: string;
project_id: string;
name: string;
kind: 'video' | 'voice';
}
export interface IngestJob {
id: string;
workspace_id: string;
project_id: string;
library_id: string;
storage_key: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
error_message: string;
result_asset_id: string;
}
export const getAssetLibraries = async (projectId: string): Promise<AssetLibraryItem[]> => {
const response = await apiClient.get('/asset-libraries', {
params: { project_id: projectId },
});
return response.data.items;
};
export const createAssetLibrary = async (data: {
workspace_id: string;
project_id: string;
name: string;
kind: 'video' | 'voice';
}): Promise<AssetLibraryItem> => {
const response = await apiClient.post('/asset-libraries', data);
return response.data;
};
export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
const response = await apiClient.get('/assets', {
params: { library_id: libraryId },
});
return response.data.items;
};
export const uploadAsset = async (formData: FormData): Promise<{ storage_key: string; ingest_job_id: string; url: string }> => {
const response = await apiClient.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
};
export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
const response = await apiClient.get(`/ingest-jobs/${jobId}`);
return response.data;
};
-9
View File
@@ -3,7 +3,6 @@
*/
import apiClient from './client';
// 类型定义
export interface Workspace {
id: string;
name: string;
@@ -32,19 +31,16 @@ export interface InviteMemberRequest {
role: 'admin' | 'member' | 'viewer';
}
// 获取工作空间列表
export const getWorkspaces = async (): Promise<Workspace[]> => {
const response = await apiClient.get('/workspaces');
return response.data;
};
// 获取工作空间详情
export const getWorkspace = async (id: string): Promise<Workspace> => {
const response = await apiClient.get(`/workspaces/${id}`);
return response.data;
};
// 创建工作空间
export const createWorkspace = async (
data: CreateWorkspaceRequest
): Promise<Workspace> => {
@@ -52,13 +48,11 @@ export const createWorkspace = async (
return response.data;
};
// 获取成员列表
export const getMembers = async (workspaceId: string): Promise<WorkspaceMember[]> => {
const response = await apiClient.get(`/workspaces/${workspaceId}/members`);
return response.data;
};
// 邀请成员
export const inviteMember = async (
workspaceId: string,
data: InviteMemberRequest
@@ -67,7 +61,6 @@ export const inviteMember = async (
return response.data;
};
// 移除成员
export const removeMember = async (
workspaceId: string,
memberId: string
@@ -76,7 +69,6 @@ export const removeMember = async (
return response.data;
};
// 更新成员角色
export const updateMemberRole = async (
workspaceId: string,
memberId: string,
@@ -88,7 +80,6 @@ export const updateMemberRole = async (
return response.data;
};
// 离开工作空间
export const leaveWorkspace = async (workspaceId: string): Promise<{ message: string }> => {
const response = await apiClient.post(`/workspaces/${workspaceId}/leave`);
return response.data;
@@ -0,0 +1,192 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Alert, Button, Card, Col, Empty, Form, Input, Modal, Row, Select, Space, Table, Tag, Upload, message } from 'antd';
import { InboxOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { useMutation, useQuery } from '@tanstack/react-query';
import { createAssetLibrary, getAssetLibraries, getAssets, getIngestJob, uploadAsset } from '@/api/assets';
const { Dragger } = Upload;
const ProjectAssets: React.FC = () => {
const { id } = useParams<{ id: string }>();
const projectId = id || '';
const [libraryId, setLibraryId] = useState<string>('');
const [workspaceId, setWorkspaceId] = useState('demo-workspace');
const [createLibraryOpen, setCreateLibraryOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const [jobId, setJobId] = useState<string>('');
const [form] = Form.useForm();
const librariesQuery = useQuery({
queryKey: ['asset-libraries', projectId],
queryFn: () => getAssetLibraries(projectId),
enabled: !!projectId,
});
useEffect(() => {
if (!libraryId && librariesQuery.data?.length) {
setLibraryId(librariesQuery.data[0].id);
}
}, [librariesQuery.data, libraryId]);
const assetsQuery = useQuery({
queryKey: ['assets', libraryId],
queryFn: () => getAssets(libraryId),
enabled: !!libraryId,
});
const ingestJobQuery = useQuery({
queryKey: ['ingest-job', jobId],
queryFn: () => getIngestJob(jobId),
enabled: !!jobId,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === 'completed' || status === 'failed' ? false : 2000;
},
});
useEffect(() => {
const status = ingestJobQuery.data?.status;
if (status === 'completed') {
setUploading(false);
assetsQuery.refetch();
message.success('素材导入完成');
}
if (status === 'failed') {
setUploading(false);
message.error(ingestJobQuery.data?.error_message || '素材导入失败');
}
}, [ingestJobQuery.data?.status]);
const createLibraryMutation = useMutation({
mutationFn: createAssetLibrary,
onSuccess: (library) => {
message.success('素材库创建成功');
setCreateLibraryOpen(false);
librariesQuery.refetch();
setLibraryId(library.id);
form.resetFields();
},
onError: () => message.error('素材库创建失败'),
});
const libraryOptions = useMemo(
() => (librariesQuery.data || []).map((item) => ({ label: `${item.name} (${item.kind})`, value: item.id })),
[librariesQuery.data]
);
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '类型', dataIndex: 'mime_type', key: 'mime_type', render: (v: string) => <Tag>{v}</Tag> },
{ title: '存储键', dataIndex: 'storage_key', key: 'storage_key', ellipsis: true },
];
const customUpload = async (options: any) => {
const { file, onSuccess, onError } = options;
if (!libraryId) {
message.warning('请先选择或创建素材库');
return;
}
try {
setUploading(true);
const formData = new FormData();
formData.append('file', file);
formData.append('workspace_id', workspaceId);
formData.append('project_id', projectId);
formData.append('library_id', libraryId);
const result = await uploadAsset(formData);
setJobId(result.ingest_job_id);
onSuccess(result);
message.info('文件已上传,正在导入处理中');
} catch (error) {
setUploading(false);
onError(error);
message.error('上传失败');
}
};
return (
<div style={{ padding: 24 }}>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={24}>
<Card title="项目素材管理" extra={<Space>
<Select
style={{ width: 280 }}
placeholder="选择素材库"
value={libraryId || undefined}
options={libraryOptions}
onChange={setLibraryId}
loading={librariesQuery.isLoading}
/>
<Button icon={<PlusOutlined />} onClick={() => setCreateLibraryOpen(true)}></Button>
<Button icon={<ReloadOutlined />} onClick={() => { librariesQuery.refetch(); assetsQuery.refetch(); }}></Button>
</Space>}>
{ingestJobQuery.data && (
<Alert
style={{ marginBottom: 16 }}
type={ingestJobQuery.data.status === 'failed' ? 'error' : 'info'}
message={`导入任务状态:${ingestJobQuery.data.status}`}
description={ingestJobQuery.data.error_message || `任务 ID: ${ingestJobQuery.data.id}`}
/>
)}
<Dragger
name="file"
multiple={false}
customRequest={customUpload}
showUploadList={false}
disabled={uploading}
style={{ marginBottom: 24 }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint"></p>
</Dragger>
{assetsQuery.data?.length ? (
<Table rowKey="id" columns={columns} dataSource={assetsQuery.data} pagination={false} />
) : (
<Empty description="当前素材库还没有素材" />
)}
</Card>
</Col>
</Row>
<Modal
title="新建素材库"
open={createLibraryOpen}
onCancel={() => setCreateLibraryOpen(false)}
onOk={() => form.submit()}
confirmLoading={createLibraryMutation.isPending}
>
<Form
form={form}
layout="vertical"
onFinish={(values) => {
createLibraryMutation.mutate({
workspace_id: workspaceId,
project_id: projectId,
name: values.name,
kind: values.kind,
});
}}
>
<Form.Item label="Workspace ID" name="workspaceIdHint">
<Input value={workspaceId} onChange={(e) => setWorkspaceId(e.target.value)} />
</Form.Item>
<Form.Item label="素材库名称" name="name" rules={[{ required: true, message: '请输入素材库名称' }]}>
<Input placeholder="例如:项目视频库" />
</Form.Item>
<Form.Item label="素材库类型" name="kind" initialValue="video" rules={[{ required: true }]}>
<Select options={[{ label: '视频库', value: 'video' }, { label: '配音库', value: 'voice' }]} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ProjectAssets;
export const Component = ProjectAssets;
@@ -2,7 +2,7 @@
* 工作空间详情页面
*/
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router-dom';
import { Tabs, Button, Descriptions, Progress, Card, Row, Col } from 'antd';
import { PlusOutlined, TeamOutlined, ProjectOutlined } from '@ant-design/icons';
import { useWorkspace } from '@/hooks/useWorkspace';
@@ -14,6 +14,7 @@ import PermissionMatrix from '@/components/business/PermissionMatrix';
const WorkspaceDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: workspace, isLoading } = useWorkspace(id!);
const { data: quota } = useQuery({
queryKey: ['quota', id],
@@ -41,15 +42,19 @@ const WorkspaceDetail: React.FC = () => {
label: '概览',
children: (
<div>
<Card title="工作空间信息" style={{ marginBottom: 16 }}>
<Card
title="工作空间信息"
style={{ marginBottom: 16 }}
extra={
<Button type="primary" onClick={() => navigate(`/projects/${id}/assets`)}>
</Button>
}
>
<Descriptions column={2}>
<Descriptions.Item label="名称">{workspace.name}</Descriptions.Item>
<Descriptions.Item label="订阅计划">
{workspace.subscription_plan}
</Descriptions.Item>
<Descriptions.Item label="状态">
{workspace.subscription_status}
</Descriptions.Item>
<Descriptions.Item label="订阅计划">{workspace.subscription_plan}</Descriptions.Item>
<Descriptions.Item label="状态">{workspace.subscription_status}</Descriptions.Item>
<Descriptions.Item label="创建时间">
{new Date(workspace.created_at).toLocaleDateString()}
</Descriptions.Item>
@@ -62,13 +67,10 @@ const WorkspaceDetail: React.FC = () => {
<Col span={12}>
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8 }}>
<ProjectOutlined /> {quota.projects.used} /{' '}
{quota.projects.limit}
<ProjectOutlined /> {quota.projects.used} / {quota.projects.limit}
</div>
<Progress
percent={Math.round(
(quota.projects.used / quota.projects.limit) * 100
)}
percent={Math.round((quota.projects.used / quota.projects.limit) * 100)}
strokeColor={getStatusColor(quota.projects.status)}
/>
</div>
@@ -76,13 +78,10 @@ const WorkspaceDetail: React.FC = () => {
<Col span={12}>
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8 }}>
{quota.storage.used_gb.toFixed(2)} GB /{' '}
{quota.storage.limit_gb} GB
{quota.storage.used_gb.toFixed(2)} GB / {quota.storage.limit_gb} GB
</div>
<Progress
percent={Math.round(
(quota.storage.used_gb / quota.storage.limit_gb) * 100
)}
percent={Math.round((quota.storage.used_gb / quota.storage.limit_gb) * 100)}
strokeColor={getStatusColor(quota.storage.status)}
/>
</div>
@@ -139,5 +138,4 @@ const WorkspaceDetail: React.FC = () => {
};
export default WorkspaceDetail;
export const Component = WorkspaceDetail;
+4
View File
@@ -58,6 +58,10 @@ export const router = createBrowserRouter([
path: 'workspaces/:id',
lazy: () => import('@/pages/workspace/WorkspaceDetail'),
},
{
path: 'projects/:id/assets',
lazy: () => import('@/pages/workspace/ProjectAssets'),
},
{
path: 'subscription',
lazy: () => import('@/pages/subscription/Plans'),
+69
View File
@@ -1,6 +1,75 @@
from datetime import datetime, timezone
import json
from .celery_app import celery_app
from packages.adapters.sqlalchemy_impl.session import SessionLocal
from packages.adapters.sqlalchemy_impl.ingest_job_repository import SQLAlchemyIngestJobRepository
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.domain import Asset, IngestJobStatus
@celery_app.task(name="worker.healthcheck")
def healthcheck() -> dict:
return {"ok": True, "service": "worker"}
@celery_app.task(name="worker.ingest_asset")
def ingest_asset(job_id: str) -> dict:
session = SessionLocal()
try:
ingest_repo = SQLAlchemyIngestJobRepository(session)
asset_repo = SQLAlchemyAssetRepository(session)
job = ingest_repo.get(job_id)
if job is None:
return {"ok": False, "error": f"job {job_id} not found"}
job.status = IngestJobStatus.PROCESSING
job.updated_at = datetime.now(timezone.utc)
ingest_repo.update(job)
storage_key = job.storage_key
filename = storage_key.split("/")[-1]
lower_name = filename.lower()
if lower_name.endswith((".mp4", ".mov", ".avi", ".mkv")):
mime_type = "video/mp4"
elif lower_name.endswith((".mp3", ".wav", ".aac")):
mime_type = "audio/mpeg"
elif lower_name.endswith((".jpg", ".jpeg")):
mime_type = "image/jpeg"
elif lower_name.endswith((".png",)):
mime_type = "image/png"
else:
mime_type = "application/octet-stream"
asset = Asset.create(
workspace_id=job.workspace_id,
project_id=job.project_id,
library_id=job.library_id,
name=filename,
storage_key=storage_key,
mime_type=mime_type,
metadata={"source": "ingest_task"},
)
asset_repo.create(asset)
job.status = IngestJobStatus.COMPLETED
job.result_asset_id = asset.id
job.updated_at = datetime.now(timezone.utc)
ingest_repo.update(job)
return {"ok": True, "job_id": job.id, "asset_id": asset.id}
except Exception as e:
try:
ingest_repo = SQLAlchemyIngestJobRepository(session)
job = ingest_repo.get(job_id)
if job is not None:
job.status = IngestJobStatus.FAILED
job.error_message = str(e)
job.updated_at = datetime.now(timezone.utc)
ingest_repo.update(job)
except Exception:
pass
return {"ok": False, "job_id": job_id, "error": str(e)}
finally:
session.close()