feat(titles): complete title selection workflow
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Failing after 2030h35m46s
Deploy / Production Browser E2E (push) Failing after 2030h26m37s
Deploy / Deploy Production (push) Failing after 2030h26m39s

This commit is contained in:
Xiaoxia AI
2026-06-24 00:55:00 +08:00
parent bad34ad001
commit 6f1a71e8bd
14 changed files with 201 additions and 19 deletions
@@ -0,0 +1,26 @@
"""Add project title favorite flag.
Revision ID: 004
Revises: 003
Create Date: 2026-06-24
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "004"
down_revision: Union[str, None] = "003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("project_titles", sa.Column("favorite", sa.Boolean(), nullable=False, server_default=sa.false()))
op.create_index(op.f("ix_project_titles_favorite"), "project_titles", ["favorite"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_project_titles_favorite"), table_name="project_titles")
op.drop_column("project_titles", "favorite")
+9 -6
View File
@@ -77,6 +77,14 @@ def _ensure_library_has_ready_video_assets(assets) -> None:
)
def _select_title_id(project_title_repository: Any, project_id: str) -> str:
active_titles = project_title_repository.list_by_project(project_id, active_only=True)
if not active_titles:
return ""
selected = sorted(active_titles, key=lambda title: (0 if getattr(title, "favorite", False) else 1, int(title.usage_count or 0), title.created_at))[0]
return selected.id
@router.post("/tasks", response_model=GenerationTaskResponse)
def create_generation_task(
request: CreateGenerationTaskRequest,
@@ -104,16 +112,11 @@ def create_generation_task(
workspace_id=request.workspace_id,
project_id=request.project_id,
asset_library_id=request.asset_library_id,
strategy_id=request.strategy_id,
strategy_id=request.strategy_id or _select_title_id(project_title_repository, request.project_id),
voice_library_id=request.voice_library_id,
created_by_user_id=authenticated_user.user.id,
)
)
if request.strategy_id:
title = project_title_repository.get(request.strategy_id)
if title is not None and title.workspace_id == request.workspace_id and title.project_id == request.project_id:
title.usage_count = int(title.usage_count or 0) + 1
project_title_repository.update(title)
celery_app.send_task("worker.generate_video", args=[task.id])
return _to_generation_task_response(task)
@@ -27,6 +27,7 @@ def _to_response(item) -> ProjectTitleResponse:
project_id=item.project_id,
text=item.text,
category=item.category,
favorite=bool(getattr(item, "favorite", False)),
usage_count=int(item.usage_count or 0),
is_active=bool(item.is_active),
created_at=item.created_at,
@@ -73,6 +74,7 @@ def create_project_title(
project_id=project_id,
text=request.text,
category=request.category,
favorite=request.favorite,
created_by_user_id=authenticated_user.user.id,
)
return _to_response(item)
@@ -94,6 +96,8 @@ def update_project_title(
item.text = request.text.strip()
if request.category is not None:
item.category = request.category
if request.favorite is not None:
item.favorite = request.favorite
if request.is_active is not None:
item.is_active = request.is_active
return _to_response(title_repository.update(item))
+3
View File
@@ -12,6 +12,7 @@ class ProjectTitleResponse(BaseModel):
project_id: str
text: str
category: str
favorite: bool
usage_count: int
is_active: bool
created_at: datetime
@@ -26,9 +27,11 @@ class CreateProjectTitleRequest(BaseModel):
workspace_id: str
text: str = Field(min_length=1, max_length=200)
category: TitleCategory = "default"
favorite: bool = False
class UpdateProjectTitleRequest(BaseModel):
text: str | None = Field(default=None, min_length=1, max_length=200)
category: TitleCategory | None = None
favorite: bool | None = None
is_active: bool | None = None
+18 -4
View File
@@ -11,7 +11,8 @@ type WorkspaceResponse = { id?: string; workspace_id?: string };
type ProjectResponse = { id: string };
type LibraryResponse = { id: string };
type AssetListResponse = { items: Array<{ name: string; status: string; mime_type?: string; file_type?: string }> };
type GenerationTaskResponse = { id: string; status: string; progress: number; result_count: number; error_message?: string | null };
type GenerationTaskResponse = { id: string; status: string; progress: number; result_count: number; error_message?: string | null; strategy_id?: string | null };
type ProjectTitleResponse = { id: string; text: string; usage_count: number };
type GeneratedVideoResponse = { id: string; name: string; file_url: string; file_size: number };
test.describe('Core generation and download flow', () => {
@@ -59,6 +60,14 @@ test.describe('Core generation and download flow', () => {
expect(library.status(), await library.text()).toBe(200);
const libraryData = (await library.json()) as LibraryResponse;
const projectTitleText = `E2E 生成标题 ${suffix}`;
const title = await request.post(`${apiBase}/projects/${projectData.id}/titles`, {
headers,
data: { workspace_id: workspaceId, text: projectTitleText, category: 'marketing', favorite: true },
});
expect(title.status(), await title.text()).toBe(200);
const titleData = (await title.json()) as ProjectTitleResponse;
const fixture = fs.readFileSync(path.join(currentDir, 'fixtures', 'sample.mp4'));
const upload = await request.post(`${apiBase}/upload`, {
headers,
@@ -103,6 +112,8 @@ test.describe('Core generation and download flow', () => {
await expect(page.getByText('项目生成任务')).toBeVisible({ timeout: 20_000 });
await page.locator('.ant-select-selector').first().click();
await page.getByText(`${libraryName} (video)`).click();
await page.getByLabel('标题').locator('.ant-select-selector').click();
await page.getByText(projectTitleText).click();
await expect(page.getByText(/素材准备度:/)).toBeVisible({ timeout: 20_000 });
await expect(page.getByRole('button', { name: '发起生成' })).toBeEnabled({ timeout: 20_000 });
@@ -127,11 +138,11 @@ test.describe('Core generation and download flow', () => {
return `http_${task.status()}`;
}
const data = (await task.json()) as GenerationTaskResponse;
return `${data.status}:${data.result_count}:${data.error_message || ''}`;
return `${data.status}:${data.result_count}:${data.strategy_id || ''}:${data.error_message || ''}`;
},
{ timeout: 90_000, intervals: [1_000, 2_000, 5_000] }
)
.toMatch(/^completed:[1-9]\d*:/);
.toMatch(new RegExp(`^completed:[1-9]\\d*:${titleData.id}:`));
const results = await request.get(`${apiBase}/generation/tasks/${createdTask.id}/results`, { headers });
expect(results.status(), await results.text()).toBe(200);
@@ -161,6 +172,9 @@ test.describe('Core generation and download flow', () => {
const sourceAsset = assetsAfterGenerationData.items.find((item) => item.name === 'e2e-generation-source.mp4');
expect(sourceAsset?.metadata.generation_use_count).toBe(1);
expect(sourceAsset?.metadata.review_status).toBe('pending_review');
expect(sourceAsset?.metadata.last_used_at).toBeTruthy();
const titleAfterGeneration = await request.get(`${apiBase}/projects/${projectData.id}/titles`, { headers });
expect(titleAfterGeneration.status(), await titleAfterGeneration.text()).toBe(200);
const titlesData = (await titleAfterGeneration.json()) as { items: ProjectTitleResponse[] };
expect(titlesData.items.find((item) => item.id === titleData.id)?.usage_count).toBe(1);
});
});
+4
View File
@@ -43,9 +43,13 @@ test.describe('Project title library flow', () => {
await page.goto(`/projects/${projectData.id}/titles`);
await expect(page.getByText('标题库')).toBeVisible({ timeout: 20_000 });
await page.getByPlaceholder('输入可复用标题').fill(`E2E 标题 ${suffix}`);
await page.getByRole('switch', { name: /普通|常用/ }).first().click();
await page.getByRole('button', { name: '新增标题' }).click();
await expect(page.getByText('标题已加入标题库')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(`E2E 标题 ${suffix}`)).toBeVisible({ timeout: 20_000 });
await expect(page.getByText('常用')).toBeVisible();
await page.getByPlaceholder('搜索标题').fill(`E2E 标题 ${suffix}`);
await expect(page.getByText(`E2E 标题 ${suffix}`)).toBeVisible();
await expect(page.getByText('使用次数:0')).toBeVisible();
});
});
+3 -2
View File
@@ -6,6 +6,7 @@ export interface ProjectTitleItem {
project_id: string;
text: string;
category: 'default' | 'marketing' | 'tutorial' | 'story' | 'promo';
favorite: boolean;
usage_count: number;
is_active: boolean;
created_at: string;
@@ -17,12 +18,12 @@ export const getProjectTitles = async (projectId: string, activeOnly = false): P
return response.data.items;
};
export const createProjectTitle = async (projectId: string, data: { workspace_id: string; text: string; category?: ProjectTitleItem['category'] }): Promise<ProjectTitleItem> => {
export const createProjectTitle = async (projectId: string, data: { workspace_id: string; text: string; category?: ProjectTitleItem['category']; favorite?: boolean }): Promise<ProjectTitleItem> => {
const response = await apiClient.post(`/projects/${projectId}/titles`, data);
return response.data;
};
export const updateProjectTitle = async (titleId: string, data: { text?: string; category?: ProjectTitleItem['category']; is_active?: boolean }): Promise<ProjectTitleItem> => {
export const updateProjectTitle = async (titleId: string, data: { text?: string; category?: ProjectTitleItem['category']; favorite?: boolean; is_active?: boolean }): Promise<ProjectTitleItem> => {
const response = await apiClient.patch(`/project-titles/${titleId}`, data);
return response.data;
};
+37 -7
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button, Card, Form, Input, List, Select, Space, Switch, Tag, message } from 'antd';
import { useParams } from 'react-router-dom';
@@ -24,7 +24,10 @@ const categoryLabels: Record<string, string> = {
const ProjectTitles: React.FC = () => {
const { id } = useParams<{ id: string }>();
const projectId = id || '';
const [form] = Form.useForm<{ text: string; category: 'default' | 'marketing' | 'tutorial' | 'story' | 'promo' }>();
const [form] = Form.useForm<{ text: string; category: 'default' | 'marketing' | 'tutorial' | 'story' | 'promo'; favorite: boolean }>();
const [searchText, setSearchText] = useState('');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const [favoriteOnly, setFavoriteOnly] = useState(false);
const queryClient = useQueryClient();
const projectQuery = useQuery({
@@ -40,11 +43,12 @@ const ProjectTitles: React.FC = () => {
});
const createMutation = useMutation({
mutationFn: (values: { text: string; category: 'default' | 'marketing' | 'tutorial' | 'story' | 'promo' }) =>
mutationFn: (values: { text: string; category: 'default' | 'marketing' | 'tutorial' | 'story' | 'promo'; favorite: boolean }) =>
createProjectTitle(projectId, {
workspace_id: projectQuery.data!.workspace_id,
text: values.text,
category: values.category,
favorite: values.favorite,
}),
onSuccess: () => {
message.success('标题已加入标题库');
@@ -55,8 +59,8 @@ const ProjectTitles: React.FC = () => {
});
const toggleMutation = useMutation({
mutationFn: ({ titleId, isActive }: { titleId: string; isActive: boolean }) =>
updateProjectTitle(titleId, { is_active: isActive }),
mutationFn: ({ titleId, isActive, favorite }: { titleId: string; isActive?: boolean; favorite?: boolean }) =>
updateProjectTitle(titleId, { is_active: isActive, favorite }),
onSuccess: () => {
message.success('标题状态已更新');
queryClient.invalidateQueries({ queryKey: ['project-titles', projectId] });
@@ -64,13 +68,23 @@ const ProjectTitles: React.FC = () => {
onError: () => message.error('标题状态更新失败'),
});
const filteredTitles = useMemo(() => {
const keyword = searchText.trim().toLowerCase();
return (titlesQuery.data || []).filter((item) => {
if (categoryFilter !== 'all' && item.category !== categoryFilter) return false;
if (favoriteOnly && !item.favorite) return false;
if (keyword && !item.text.toLowerCase().includes(keyword)) return false;
return true;
});
}, [categoryFilter, favoriteOnly, searchText, titlesQuery.data]);
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Card title="标题库" extra={<Button onClick={() => titlesQuery.refetch()}>刷新</Button>}>
<Form
form={form}
layout="inline"
initialValues={{ category: 'default' }}
initialValues={{ category: 'default', favorite: false }}
onFinish={(values) => createMutation.mutate(values)}
>
<Form.Item name="text" rules={[{ required: true, message: '请输入标题' }, { max: 200, message: '标题最多 200 字' }]} style={{ flex: 1 }}>
@@ -79,6 +93,9 @@ const ProjectTitles: React.FC = () => {
<Form.Item name="category">
<Select options={categoryOptions} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="favorite" valuePropName="checked">
<Switch checkedChildren="常用" unCheckedChildren="普通" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={createMutation.isPending} disabled={!projectQuery.data}>
新增标题
@@ -88,13 +105,25 @@ const ProjectTitles: React.FC = () => {
</Card>
<Card title="项目标题">
<Space wrap style={{ marginBottom: 16 }}>
<Input.Search placeholder="搜索标题" allowClear onSearch={setSearchText} onChange={(event) => setSearchText(event.target.value)} style={{ width: 220 }} />
<Select value={categoryFilter} onChange={setCategoryFilter} style={{ width: 140 }} options={[{ label: '全部分类', value: 'all' }, ...categoryOptions]} />
<Switch checked={favoriteOnly} onChange={setFavoriteOnly} checkedChildren="仅常用" unCheckedChildren="全部" />
</Space>
<List
loading={titlesQuery.isLoading}
dataSource={titlesQuery.data || []}
dataSource={filteredTitles}
locale={{ emptyText: '暂无标题,先新增一个标题用于生成脚本和成片命名' }}
renderItem={(item) => (
<List.Item
actions={[
<Switch
key="favorite"
checked={item.favorite}
checkedChildren="常用"
unCheckedChildren="普通"
onChange={(checked) => toggleMutation.mutate({ titleId: item.id, favorite: checked })}
/>,
<Switch
key="active"
checked={item.is_active}
@@ -108,6 +137,7 @@ const ProjectTitles: React.FC = () => {
title={
<Space wrap>
<span>{item.text}</span>
{item.favorite && <Tag color="gold">常用</Tag>}
<Tag color={item.is_active ? 'green' : 'default'}>{item.is_active ? '启用' : '停用'}</Tag>
<Tag>{categoryLabels[item.category] || item.category}</Tag>
</Space>
@@ -0,0 +1,15 @@
from datetime import datetime, timezone
from packages.adapters.sqlalchemy_impl.models import ProjectTitleModel
def mark_title_used_for_generation(db, task) -> None:
if not task.strategy_id:
return
title = db.query(ProjectTitleModel).filter(ProjectTitleModel.id == task.strategy_id).first()
if title is None or title.workspace_id != task.workspace_id or title.project_id != task.project_id:
return
title.usage_count = int(title.usage_count or 0) + 1
title.updated_at = datetime.now(timezone.utc)
db.add(title)
db.commit()
@@ -9,6 +9,7 @@ from urllib.parse import urlparse
import oss2
from worker_app.core.asset_usage import mark_asset_used_for_generation
from worker_app.core.title_usage import mark_title_used_for_generation
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
@@ -249,6 +250,7 @@ def generate_video(task_id: str) -> dict:
task.error_message = ""
task.completed_at = datetime.now(timezone.utc)
task_repo.update(task)
mark_title_used_for_generation(db, task)
return {
"status": "completed",
@@ -123,6 +123,7 @@ class ProjectTitleModel(Base):
project_id = Column(String(36), nullable=False, index=True)
text = Column(String(200), nullable=False)
category = Column(String(50), nullable=False, default="default", index=True)
favorite = Column(Boolean, nullable=False, default=False, index=True)
usage_count = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, nullable=False, default=True)
created_by_user_id = Column(String(36), nullable=False)
@@ -25,6 +25,7 @@ class SQLAlchemyProjectTitleRepository:
text: str,
category: str,
created_by_user_id: str,
favorite: bool = False,
) -> ProjectTitleModel:
now = datetime.now(timezone.utc)
item = ProjectTitleModel(
@@ -33,6 +34,7 @@ class SQLAlchemyProjectTitleRepository:
project_id=project_id,
text=text.strip(),
category=category,
favorite=favorite,
usage_count=0,
is_active=True,
created_by_user_id=created_by_user_id,
@@ -0,0 +1,75 @@
import sys
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.api.routes.generation_tasks import _select_title_id
from packages.adapters.sqlalchemy_impl.models import Base, ProjectTitleModel
from packages.adapters.sqlalchemy_impl.project_title_repository import SQLAlchemyProjectTitleRepository
from worker_app.core.title_usage import mark_title_used_for_generation
def _session_and_repository():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine)()
return session, SQLAlchemyProjectTitleRepository(session)
def test_select_title_prefers_favorite_then_lowest_usage():
_, repository = _session_and_repository()
normal = repository.create(
workspace_id="workspace-1",
project_id="project-1",
text="普通标题",
category="default",
created_by_user_id="user-1",
)
favorite = repository.create(
workspace_id="workspace-1",
project_id="project-1",
text="常用标题",
category="default",
favorite=True,
created_by_user_id="user-1",
)
normal.usage_count = 0
favorite.usage_count = 10
repository.update(normal)
repository.update(favorite)
assert _select_title_id(repository, "project-1") == favorite.id
def test_mark_title_used_after_generation_completion():
session, _ = _session_and_repository()
now = datetime.now(timezone.utc)
title = ProjectTitleModel(
id="title-1",
workspace_id="workspace-1",
project_id="project-1",
text="生成标题",
category="default",
favorite=False,
usage_count=2,
is_active=True,
created_by_user_id="user-1",
created_at=now,
updated_at=now,
)
session.add(title)
session.commit()
mark_title_used_for_generation(
session,
SimpleNamespace(strategy_id="title-1", workspace_id="workspace-1", project_id="project-1"),
)
updated = session.query(ProjectTitleModel).filter(ProjectTitleModel.id == "title-1").first()
assert updated.usage_count == 3
@@ -25,11 +25,13 @@ def test_project_title_repository_creates_and_lists_titles():
project_id="project-1",
text=" 3 分钟看懂产品亮点 ",
category="marketing",
favorite=True,
created_by_user_id="user-1",
)
assert title.text == "3 分钟看懂产品亮点"
assert title.category == "marketing"
assert title.favorite is True
assert title.usage_count == 0
assert title.is_active is True
assert repository.list_by_project("project-1") == [title]