真人人像修改
This commit is contained in:
Vendored
+6
-6
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-Dfcsk8qj.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-kx3oQI_t.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -456,6 +456,10 @@ export async function updateUserMenus(userId: string, allowedMenus: string[] | n
|
||||
await api.put(`/admin/users/${userId}/menus`, { allowed_menus: allowedMenus });
|
||||
}
|
||||
|
||||
export async function updateUserAdminStatus(userId: string, isAdmin: boolean): Promise<void> {
|
||||
await api.put(`/admin/users/${userId}/admin-status`, { is_admin: isAdmin });
|
||||
}
|
||||
|
||||
export async function resetUserPassword(userId: string, newPassword: string): Promise<void> {
|
||||
await api.put(`/admin/users/${userId}/reset-password`, { new_password: newPassword });
|
||||
}
|
||||
|
||||
@@ -287,7 +287,6 @@ const AdminLayout: React.FC = () => {
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
padding: 12,
|
||||
background: 'linear-gradient(135deg, rgba(248, 250, 252, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid rgba(0, 0, 0, 0.06)',
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined,
|
||||
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
adjustCredits,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
updateUserTeam,
|
||||
updateSystemConfig,
|
||||
updateUserMenus,
|
||||
updateUserAdminStatus,
|
||||
} from '../api';
|
||||
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
@@ -379,6 +380,16 @@ const AdminUsers: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAdminStatus = async (user: AdminUser) => {
|
||||
try {
|
||||
await updateUserAdminStatus(user.id, !user.isAdmin);
|
||||
message.success(user.isAdmin ? '已取消超级管理员' : '已设为超级管理员');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '设置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const isAdminTab = activeTab === 'admin';
|
||||
|
||||
const columns = [
|
||||
@@ -397,7 +408,7 @@ const AdminUsers: React.FC = () => {
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{r.username}
|
||||
{r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}>管理员</Tag>}
|
||||
{r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}>超级管理员</Tag>}
|
||||
</div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div>
|
||||
</div>
|
||||
@@ -476,7 +487,7 @@ const AdminUsers: React.FC = () => {
|
||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 540, fixed: 'right' as const,
|
||||
title: '操作', key: 'action', width: 560, fixed: 'right' as const,
|
||||
render: (_: any, r: AdminUser) => (
|
||||
<Space size={4} wrap>
|
||||
{!isAdminTab && (
|
||||
@@ -509,6 +520,17 @@ const AdminUsers: React.FC = () => {
|
||||
{!isAdminTab && r.frontendUserKind === 'internal' && (
|
||||
<Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'external')}>取消内部</Button>
|
||||
)}
|
||||
{isAdminTab && (
|
||||
<Popconfirm
|
||||
title={r.isAdmin ? '确定取消该用户的超级管理员权限?' : '确定将该用户设为超级管理员?'}
|
||||
onConfirm={() => handleToggleAdminStatus(r)}
|
||||
>
|
||||
<Button type="link" size="small" style={{ color: r.isAdmin ? '#f59e0b' : '#6366f1' }}
|
||||
icon={<SecurityScanOutlined />}>
|
||||
{r.isAdmin ? '取消超级管理员' : '设为超级管理员'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Button type="link" size="small" icon={<MenuOutlined />}
|
||||
onClick={() => openMenuModal(r)}>
|
||||
菜单权限
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""add upload resource physical delete status
|
||||
|
||||
Revision ID: 1475d11b1d74
|
||||
Revises: cd7688999204
|
||||
Create Date: 2026-07-08 15:14:13.995718
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1475d11b1d74'
|
||||
down_revision: Union[str, None] = 'cd7688999204'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('upload_resources',
|
||||
sa.Column('id', sa.String(length=32), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=32), nullable=False),
|
||||
sa.Column('module', sa.String(length=64), nullable=False),
|
||||
sa.Column('resource_type', sa.String(length=32), nullable=False),
|
||||
sa.Column('resource_url', sa.Text(), nullable=False),
|
||||
sa.Column('storage_path', sa.Text(), nullable=False),
|
||||
sa.Column('file_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('file_ext', sa.String(length=32), nullable=True),
|
||||
sa.Column('mime_type', sa.String(length=128), nullable=True),
|
||||
sa.Column('file_size_bytes', sa.BigInteger(), server_default='0', nullable=False),
|
||||
sa.Column('duration_seconds', sa.Float(), nullable=True),
|
||||
sa.Column('duration_source', sa.String(length=32), nullable=True),
|
||||
sa.Column('width', sa.BigInteger(), nullable=True),
|
||||
sa.Column('height', sa.BigInteger(), nullable=True),
|
||||
sa.Column('source_model', sa.String(length=64), nullable=True),
|
||||
sa.Column('source_id', sa.String(length=32), nullable=True),
|
||||
sa.Column('source_module', sa.String(length=64), nullable=True),
|
||||
sa.Column('bind_status', sa.String(length=32), server_default='pending', nullable=False),
|
||||
sa.Column('delete_policy', sa.String(length=32), server_default='user_deletable', nullable=False),
|
||||
sa.Column('created_by', sa.String(length=32), server_default='api', nullable=False),
|
||||
sa.Column('metadata_json', sa.Text(), nullable=True),
|
||||
sa.Column('capacity_released_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('physical_deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('file_delete_status', sa.String(length=32), server_default='active', nullable=False),
|
||||
sa.Column('file_delete_error', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('storage_path', name='uq_upload_resources_storage_path')
|
||||
)
|
||||
op.create_index(op.f('ix_upload_resources_bind_status'), 'upload_resources', ['bind_status'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_capacity_released_at'), 'upload_resources', ['capacity_released_at'], unique=False)
|
||||
op.create_index('ix_upload_resources_created', 'upload_resources', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_created_by'), 'upload_resources', ['created_by'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_delete_policy'), 'upload_resources', ['delete_policy'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_deleted_at'), 'upload_resources', ['deleted_at'], unique=False)
|
||||
op.create_index('ix_upload_resources_file_delete_status', 'upload_resources', ['file_delete_status'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_module'), 'upload_resources', ['module'], unique=False)
|
||||
op.create_index('ix_upload_resources_physical_deleted', 'upload_resources', ['physical_deleted_at'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_resource_type'), 'upload_resources', ['resource_type'], unique=False)
|
||||
op.create_index('ix_upload_resources_source', 'upload_resources', ['source_model', 'source_id', 'deleted_at'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_source_id'), 'upload_resources', ['source_id'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_source_model'), 'upload_resources', ['source_model'], unique=False)
|
||||
op.create_index('ix_upload_resources_url', 'upload_resources', ['resource_url'], unique=False)
|
||||
op.create_index('ix_upload_resources_user_bind', 'upload_resources', ['user_id', 'bind_status', 'deleted_at'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_user_id'), 'upload_resources', ['user_id'], unique=False)
|
||||
op.create_index('ix_upload_resources_user_module_type', 'upload_resources', ['user_id', 'module', 'resource_type', 'deleted_at'], unique=False)
|
||||
op.create_index(
|
||||
'ix_upload_resources_history_lookup',
|
||||
'upload_resources',
|
||||
['user_id', 'resource_type', sa.text('created_at DESC')],
|
||||
unique=False,
|
||||
postgresql_where=sa.text(
|
||||
"deleted_at IS NULL "
|
||||
"AND bind_status = 'pending' "
|
||||
"AND delete_policy = 'user_deletable' "
|
||||
"AND source_model IS NULL "
|
||||
"AND source_id IS NULL"
|
||||
),
|
||||
)
|
||||
op.add_column('user_resource_month_stats', sa.Column('upload_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_month_stats', sa.Column('audio_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_month_stats', sa.Column('shot_segment_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_month_stats', sa.Column('upload_count', sa.Integer(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_month_stats', sa.Column('audio_count', sa.Integer(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_month_stats', sa.Column('shot_segment_count', sa.Integer(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_total_stats', sa.Column('upload_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_total_stats', sa.Column('audio_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_total_stats', sa.Column('shot_segment_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_total_stats', sa.Column('upload_count', sa.Integer(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_total_stats', sa.Column('audio_count', sa.Integer(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_total_stats', sa.Column('shot_segment_count', sa.Integer(), server_default='0', nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('user_resource_total_stats', 'shot_segment_count')
|
||||
op.drop_column('user_resource_total_stats', 'audio_count')
|
||||
op.drop_column('user_resource_total_stats', 'upload_count')
|
||||
op.drop_column('user_resource_total_stats', 'shot_segment_size_bytes')
|
||||
op.drop_column('user_resource_total_stats', 'audio_size_bytes')
|
||||
op.drop_column('user_resource_total_stats', 'upload_size_bytes')
|
||||
op.drop_column('user_resource_month_stats', 'shot_segment_count')
|
||||
op.drop_column('user_resource_month_stats', 'audio_count')
|
||||
op.drop_column('user_resource_month_stats', 'upload_count')
|
||||
op.drop_column('user_resource_month_stats', 'shot_segment_size_bytes')
|
||||
op.drop_column('user_resource_month_stats', 'audio_size_bytes')
|
||||
op.drop_column('user_resource_month_stats', 'upload_size_bytes')
|
||||
op.drop_index('ix_upload_resources_history_lookup', table_name='upload_resources')
|
||||
op.drop_index('ix_upload_resources_user_module_type', table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_user_id'), table_name='upload_resources')
|
||||
op.drop_index('ix_upload_resources_user_bind', table_name='upload_resources')
|
||||
op.drop_index('ix_upload_resources_url', table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_source_model'), table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_source_id'), table_name='upload_resources')
|
||||
op.drop_index('ix_upload_resources_source', table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_resource_type'), table_name='upload_resources')
|
||||
op.drop_index('ix_upload_resources_physical_deleted', table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_module'), table_name='upload_resources')
|
||||
op.drop_index('ix_upload_resources_file_delete_status', table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_deleted_at'), table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_delete_policy'), table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_created_by'), table_name='upload_resources')
|
||||
op.drop_index('ix_upload_resources_created', table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_capacity_released_at'), table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_bind_status'), table_name='upload_resources')
|
||||
op.drop_table('upload_resources')
|
||||
# ### end Alembic commands ###
|
||||
@@ -5,6 +5,8 @@ from app.api.admin.resource_capacity import router as resource_capacity_router
|
||||
from app.api.admin.team import router as team_router
|
||||
from app.api.admin.home_material import router as home_material_router
|
||||
from app.api.admin.private_portrait import router as private_portrait_router
|
||||
from app.api.admin.recharge_package import router as recharge_package_router
|
||||
from app.api.admin.menu_config import router as menu_config_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(video_prompt_schema_config_router)
|
||||
@@ -12,3 +14,5 @@ router.include_router(resource_capacity_router)
|
||||
router.include_router(team_router)
|
||||
router.include_router(home_material_router)
|
||||
router.include_router(private_portrait_router)
|
||||
router.include_router(recharge_package_router)
|
||||
router.include_router(menu_config_router)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, get_admin_user, get_backend_user
|
||||
from app.models.user import User
|
||||
from app.models.menu_config import MenuConfig
|
||||
from app.schemas.menu import MenuConfigCreate, MenuConfigOut
|
||||
from app.services.operation_log import log_operation
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
router = APIRouter(prefix="/admin/menu-configs", tags=["admin-menu-configs"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[MenuConfigOut])
|
||||
async def admin_list_menu_configs(
|
||||
admin: User = Depends(get_backend_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(MenuConfig).order_by(MenuConfig.sort_order))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("", response_model=MenuConfigOut)
|
||||
async def create_menu_config(
|
||||
req: MenuConfigCreate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
menu = MenuConfig(id=generate_id(), **req.model_dump())
|
||||
db.add(menu)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"创建菜单 {menu.label}",
|
||||
"POST",
|
||||
"/admin/menu-configs",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"id": menu.id,
|
||||
"label": menu.label,
|
||||
"path": menu.path,
|
||||
"menu_target": menu.menu_target,
|
||||
"menu_type": menu.menu_type,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return menu
|
||||
|
||||
|
||||
@router.put("/{menu_id}", response_model=MenuConfigOut)
|
||||
async def update_menu_config(
|
||||
menu_id: str,
|
||||
req: MenuConfigCreate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(MenuConfig).where(MenuConfig.id == menu_id).limit(1))
|
||||
menu = result.scalar_one_or_none()
|
||||
if not menu:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="菜单不存在")
|
||||
before_label = menu.label
|
||||
for k, v in req.model_dump().items():
|
||||
setattr(menu, k, v)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"更新菜单 {before_label}",
|
||||
"PUT",
|
||||
f"/admin/menu-configs/{menu_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"id": menu_id,
|
||||
"before": {"label": before_label},
|
||||
"after": {
|
||||
"label": menu.label,
|
||||
"path": menu.path,
|
||||
"is_active": menu.is_active,
|
||||
"sort_order": menu.sort_order,
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return menu
|
||||
|
||||
|
||||
@router.delete("/{menu_id}")
|
||||
async def delete_menu_config(
|
||||
menu_id: str,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(MenuConfig).where(MenuConfig.id == menu_id).limit(1))
|
||||
menu = result.scalar_one_or_none()
|
||||
if not menu:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="菜单不存在")
|
||||
menu_label = menu.label
|
||||
await db.delete(menu)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"删除菜单 {menu_label}",
|
||||
"DELETE",
|
||||
f"/admin/menu-configs/{menu_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"id": menu_id,
|
||||
"label": menu_label,
|
||||
"path": menu.path,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok"}
|
||||
@@ -0,0 +1,145 @@
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, get_admin_user
|
||||
from app.models.user import User
|
||||
from app.models.recharge_package import RechargePackage
|
||||
from app.schemas.recharge_package import (
|
||||
RechargePackageCreate,
|
||||
RechargePackageUpdate,
|
||||
RechargePackageOut,
|
||||
)
|
||||
from app.services.operation_log import log_operation
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
router = APIRouter(prefix="/admin/recharge-packages", tags=["admin-recharge-packages"])
|
||||
|
||||
|
||||
def _to_out(pkg: RechargePackage) -> dict:
|
||||
return {
|
||||
"id": pkg.id,
|
||||
"name": pkg.name,
|
||||
"credits": round(pkg.credits, 2),
|
||||
"price": round(pkg.price, 2),
|
||||
"bonus_credits": round(pkg.bonus_credits, 2),
|
||||
"total_credits": round(pkg.credits + pkg.bonus_credits, 2),
|
||||
"description": pkg.description,
|
||||
"package_type": pkg.package_type,
|
||||
"is_gift": pkg.is_gift,
|
||||
"is_active": pkg.is_active,
|
||||
"sort_order": pkg.sort_order,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=list[RechargePackageOut])
|
||||
async def admin_list_packages(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(RechargePackage).order_by(RechargePackage.sort_order)
|
||||
)
|
||||
return [_to_out(p) for p in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("", response_model=RechargePackageOut)
|
||||
async def create_package(
|
||||
data: RechargePackageCreate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
pkg = RechargePackage(id=generate_id(), **data.model_dump())
|
||||
db.add(pkg)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"创建充值套餐 {pkg.name}",
|
||||
"POST",
|
||||
"/admin/recharge-packages",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"id": pkg.id,
|
||||
"name": pkg.name,
|
||||
"price": round(pkg.price, 2),
|
||||
"credits": round(pkg.credits, 2),
|
||||
"bonus_credits": round(pkg.bonus_credits, 2),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return _to_out(pkg)
|
||||
|
||||
|
||||
@router.put("/{pkg_id}", response_model=RechargePackageOut)
|
||||
async def update_package(
|
||||
pkg_id: str,
|
||||
data: RechargePackageUpdate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(RechargePackage).where(RechargePackage.id == pkg_id).limit(1)
|
||||
)
|
||||
pkg = result.scalar_one_or_none()
|
||||
if not pkg:
|
||||
raise HTTPException(status_code=404, detail="套餐不存在")
|
||||
before = _to_out(pkg)
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(pkg, k, v)
|
||||
await db.flush()
|
||||
after = _to_out(pkg)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"更新充值套餐 {pkg.name}",
|
||||
"PUT",
|
||||
f"/admin/recharge-packages/{pkg_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"id": pkg_id,
|
||||
"before": before,
|
||||
"after": after,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return _to_out(pkg)
|
||||
|
||||
|
||||
@router.delete("/{pkg_id}")
|
||||
async def delete_package(
|
||||
pkg_id: str,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(RechargePackage).where(RechargePackage.id == pkg_id).limit(1)
|
||||
)
|
||||
pkg = result.scalar_one_or_none()
|
||||
if not pkg:
|
||||
raise HTTPException(status_code=404, detail="套餐不存在")
|
||||
pkg_name = pkg.name
|
||||
await db.delete(pkg)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"删除充值套餐 {pkg_name}",
|
||||
"DELETE",
|
||||
f"/admin/recharge-packages/{pkg_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"id": pkg_id,
|
||||
"name": pkg_name,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"ok": True}
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -21,6 +23,7 @@ from app.services.video_prompt_schema_config_service import (
|
||||
reset_video_prompt_schema_config,
|
||||
save_video_prompt_schema_config,
|
||||
)
|
||||
from app.services.operation_log import log_operation
|
||||
|
||||
router = APIRouter(prefix="/admin/video-prompt-schema-config", tags=["admin-video-prompt-schema-config"])
|
||||
|
||||
@@ -40,8 +43,22 @@ async def save_config(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = admin
|
||||
return await save_video_prompt_schema_config(db, data=req.data, is_enabled=req.is_enabled)
|
||||
result = await save_video_prompt_schema_config(db, data=req.data, is_enabled=req.is_enabled)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
"保存视频提词 Schema 配置",
|
||||
"PUT",
|
||||
"/admin/video-prompt-schema-config",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"is_enabled": req.is_enabled,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/reset-default", response_model=VideoPromptSchemaConfigOut, summary="恢复默认视频提词 Schema 配置")
|
||||
@@ -49,8 +66,22 @@ async def reset_default(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = admin
|
||||
return await reset_video_prompt_schema_config(db)
|
||||
result = await reset_video_prompt_schema_config(db)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
"恢复默认视频提词 Schema 配置",
|
||||
"POST",
|
||||
"/admin/video-prompt-schema-config/reset-default",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"reset_to_default": True,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/export", response_model=VideoPromptSchemaExportOut, summary="导出视频提词 Schema 配置")
|
||||
@@ -68,8 +99,22 @@ async def import_config(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = admin
|
||||
return await import_video_prompt_schema_config(db, data=req.data, is_enabled=req.is_enabled)
|
||||
result = await import_video_prompt_schema_config(db, data=req.data, is_enabled=req.is_enabled)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
"导入视频提词 Schema 配置",
|
||||
"POST",
|
||||
"/admin/video-prompt-schema-config/import",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"is_enabled": req.is_enabled,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/preview", response_model=VideoPromptSchemaPreviewOut, summary="预览视频提词运行时 Schema")
|
||||
|
||||
@@ -34,6 +34,7 @@ from app.api.admin import router as admin_module_router
|
||||
from app.api.v1.material_admin import router as material_admin_router
|
||||
from app.api.v1.private_portrait import router as private_portrait_router
|
||||
from app.api.v1.private_portrait_virtual import router as private_portrait_virtual_router
|
||||
from app.api.v1.upload_resource import router as upload_resource_router
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth_router)
|
||||
@@ -70,3 +71,4 @@ api_router.include_router(admin_module_router)
|
||||
api_router.include_router(material_admin_router)
|
||||
api_router.include_router(private_portrait_router)
|
||||
api_router.include_router(private_portrait_virtual_router)
|
||||
api_router.include_router(upload_resource_router)
|
||||
|
||||
@@ -218,7 +218,21 @@ async def update_user_menus(
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
user.allowed_menus = req.allowed_menus
|
||||
await db.flush()
|
||||
await log_operation(db, admin.id, admin.username, f"更新菜单权限", "PUT", f"/admin/users/{user_id}/menus")
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"更新菜单权限",
|
||||
"PUT",
|
||||
f"/admin/users/{user_id}/menus",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"allowed_menus": req.allowed_menus,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@@ -259,7 +273,22 @@ async def adjust_credits(
|
||||
f"您的积分已{'增加' if req.amount > 0 else '扣除'}{abs(req.amount)}积分。原因:{req.description}",
|
||||
"credit",
|
||||
)
|
||||
await log_operation(db, admin.id, admin.username, f"调整积分 {'+' if req.amount > 0 else ''}{req.amount}", "POST", f"/admin/users/{user_id}/credits")
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"调整积分 {'+' if req.amount > 0 else ''}{req.amount}",
|
||||
"POST",
|
||||
f"/admin/users/{user_id}/credits",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"amount": req.amount,
|
||||
"description": req.description,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@@ -275,7 +304,55 @@ async def update_user_status(
|
||||
update(User).where(User.id == user_id).values(is_active=is_active)
|
||||
)
|
||||
await db.flush()
|
||||
await log_operation(db, admin.id, admin.username, f"{'启用' if is_active else '禁用'}用户", "PUT", f"/admin/users/{user_id}/status")
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"{'启用' if is_active else '禁用'}用户",
|
||||
"PUT",
|
||||
f"/admin/users/{user_id}/status",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"is_active": is_active,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/admin-status")
|
||||
async def update_user_admin_status(
|
||||
user_id: str,
|
||||
body: dict,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
is_admin = body.get("is_admin", False)
|
||||
result = await db.execute(select(User).where(User.id == user_id).limit(1))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
if user.user_type != "admin":
|
||||
raise HTTPException(status_code=400, detail="仅后台用户支持设置超级管理员状态")
|
||||
user.is_admin = is_admin
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"{is_admin and '设为' or '取消'}超级管理员",
|
||||
"PUT",
|
||||
f"/admin/users/{user_id}/admin-status",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"is_admin": is_admin,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@@ -294,7 +371,21 @@ async def update_user_frontend_kind(
|
||||
raise HTTPException(status_code=400, detail="仅前台用户支持设置内部/外部归类")
|
||||
user.frontend_user_kind = req.frontend_user_kind or FrontendUserKind.EXTERNAL.value
|
||||
await db.flush()
|
||||
await log_operation(db, admin.id, admin.username, f"设置前台用户归类为 {user.frontend_user_kind}", "PUT", f"/admin/users/{user_id}/frontend-kind")
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"设置前台用户归类为 {user.frontend_user_kind}",
|
||||
"PUT",
|
||||
f"/admin/users/{user_id}/frontend-kind",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"frontend_user_kind": user.frontend_user_kind,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@@ -334,7 +425,21 @@ async def reset_user_password(
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
user.hashed_password = hash_password(req.new_password)
|
||||
await db.flush()
|
||||
await log_operation(db, admin.id, admin.username, f"重置密码", "PUT", f"/admin/users/{user_id}/reset-password")
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"重置密码",
|
||||
"PUT",
|
||||
f"/admin/users/{user_id}/reset-password",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"new_password": "***",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@@ -352,7 +457,21 @@ async def admin_change_password(
|
||||
raise HTTPException(status_code=400, detail="密码至少6位")
|
||||
admin.hashed_password = hash_password(new_password)
|
||||
await db.flush()
|
||||
await log_operation(db, admin.id, admin.username, "修改密码", "POST", "/admin/change-password")
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
"修改密码",
|
||||
"POST",
|
||||
"/admin/change-password",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"old_password": "***",
|
||||
"new_password": "***",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "密码修改成功"}
|
||||
|
||||
|
||||
@@ -473,6 +592,22 @@ async def create_admin_notification(
|
||||
db, None, title, content, notif_type, push_ws=True
|
||||
)
|
||||
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"{'发送通知给指定用户' if target_user_id else '广播通知'}: {title}",
|
||||
"POST",
|
||||
"/admin/notifications",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"title": title,
|
||||
"target_user_id": target_user_id,
|
||||
"type": notif_type,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@@ -493,6 +628,21 @@ async def delete_admin_notification(
|
||||
)
|
||||
await db.delete(notif)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"删除通知: {notif.title}",
|
||||
"DELETE",
|
||||
f"/admin/notifications/{notification_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"notification_id": notification_id,
|
||||
"title": notif.title,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@@ -557,6 +707,15 @@ async def batch_update_payment_configs(
|
||||
value=value,
|
||||
))
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
"批量更新支付配置",
|
||||
"PUT",
|
||||
"/admin/payment-configs/batch",
|
||||
detail=json.dumps(list(req.keys()), ensure_ascii=False),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -747,6 +906,22 @@ async def update_payment_config(
|
||||
raise HTTPException(status_code=404, detail="支付配置不存在")
|
||||
config.value = req.value
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"更新支付配置: {config.key}",
|
||||
"PUT",
|
||||
f"/admin/payment-configs/{config_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"config_id": config_id,
|
||||
"key": config.key,
|
||||
"value": req.value,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {
|
||||
"id": config.id,
|
||||
"key": config.key,
|
||||
@@ -765,6 +940,20 @@ async def refund_payment_order(
|
||||
result = await process_refund(db, order_no)
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=400, detail=result.get("message", "退款失败"))
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"订单退款: {order_no}",
|
||||
"POST",
|
||||
f"/admin/payment-orders/{order_no}/refund",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"order_no": order_no,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -822,6 +1011,22 @@ async def create_industry_config(
|
||||
)
|
||||
db.add(config)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"创建行业配置: {req.label}",
|
||||
"POST",
|
||||
"/admin/industry-configs",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"config_id": config.id,
|
||||
"key": req.key,
|
||||
"label": req.label,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return _serialize_industry(config)
|
||||
|
||||
|
||||
@@ -846,6 +1051,22 @@ async def update_industry_config(
|
||||
config.is_active = req.is_active
|
||||
config.sort_order = req.sort_order
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"更新行业配置: {req.label}",
|
||||
"PUT",
|
||||
f"/admin/industry-configs/{config_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"config_id": config_id,
|
||||
"key": req.key,
|
||||
"label": req.label,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return _serialize_industry(config)
|
||||
|
||||
|
||||
@@ -863,6 +1084,22 @@ async def delete_industry_config(
|
||||
raise HTTPException(status_code=404, detail="行业配置不存在")
|
||||
await db.delete(config)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"删除行业配置: {config.label}",
|
||||
"DELETE",
|
||||
f"/admin/industry-configs/{config_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"config_id": config_id,
|
||||
"key": config.key,
|
||||
"label": config.label,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@@ -888,6 +1125,21 @@ async def create_video_engine(
|
||||
engine = VideoEngine(id=generate_id(), **req.model_dump())
|
||||
db.add(engine)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"创建视频引擎: {req.name}",
|
||||
"POST",
|
||||
"/admin/video-engines",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"engine_id": engine.id,
|
||||
"name": req.name,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return engine
|
||||
|
||||
|
||||
@@ -907,6 +1159,21 @@ async def update_video_engine(
|
||||
for k, v in req.model_dump().items():
|
||||
setattr(engine, k, v)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"更新视频引擎: {engine.name}",
|
||||
"PUT",
|
||||
f"/admin/video-engines/{engine_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"engine_id": engine_id,
|
||||
"name": engine.name,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return engine
|
||||
|
||||
|
||||
@@ -924,6 +1191,21 @@ async def delete_video_engine(
|
||||
raise HTTPException(status_code=404, detail="视频引擎不存在")
|
||||
await db.delete(engine)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"删除视频引擎: {engine.name}",
|
||||
"DELETE",
|
||||
f"/admin/video-engines/{engine_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"engine_id": engine_id,
|
||||
"name": engine.name,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@@ -949,6 +1231,21 @@ async def create_image_engine(
|
||||
engine = ImageEngine(id=generate_id(), **req.model_dump())
|
||||
db.add(engine)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"创建图片引擎: {req.name}",
|
||||
"POST",
|
||||
"/admin/image-engines",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"engine_id": engine.id,
|
||||
"name": req.name,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return engine
|
||||
|
||||
|
||||
@@ -968,6 +1265,21 @@ async def update_image_engine(
|
||||
for k, v in req.model_dump().items():
|
||||
setattr(engine, k, v)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"更新图片引擎: {engine.name}",
|
||||
"PUT",
|
||||
f"/admin/image-engines/{engine_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"engine_id": engine_id,
|
||||
"name": engine.name,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return engine
|
||||
|
||||
|
||||
@@ -985,6 +1297,21 @@ async def delete_image_engine(
|
||||
raise HTTPException(status_code=404, detail="图片引擎不存在")
|
||||
await db.delete(engine)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"删除图片引擎: {engine.name}",
|
||||
"DELETE",
|
||||
f"/admin/image-engines/{engine_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"engine_id": engine_id,
|
||||
"name": engine.name,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@@ -1036,6 +1363,22 @@ async def create_credit_ratio(
|
||||
ratio = CreditRatio(id=generate_id(), **data)
|
||||
db.add(ratio)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"创建积分规则: {data.get('gen_type', '')} - {data.get('model_config_id', '')}",
|
||||
"POST",
|
||||
"/admin/credit-ratios",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"ratio_id": ratio.id,
|
||||
"gen_type": data.get("gen_type"),
|
||||
"model_config_id": data.get("model_config_id"),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return ratio
|
||||
|
||||
|
||||
@@ -1059,6 +1402,22 @@ async def update_credit_ratio(
|
||||
for k, v in data.items():
|
||||
setattr(ratio, k, v)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"更新积分规则: {data.get('gen_type', '')} - {data.get('model_config_id', '')}",
|
||||
"PUT",
|
||||
f"/admin/credit-ratios/{ratio_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"ratio_id": ratio_id,
|
||||
"gen_type": data.get("gen_type"),
|
||||
"model_config_id": data.get("model_config_id"),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return ratio
|
||||
|
||||
|
||||
@@ -1076,6 +1435,22 @@ async def delete_credit_ratio(
|
||||
raise HTTPException(status_code=404, detail="积分比例不存在")
|
||||
await db.delete(ratio)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"删除积分规则: {ratio.gen_type} - {ratio.model_config_id}",
|
||||
"DELETE",
|
||||
f"/admin/credit-ratios/{ratio_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"ratio_id": ratio_id,
|
||||
"gen_type": ratio.gen_type,
|
||||
"model_config_id": ratio.model_config_id,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@@ -1116,6 +1491,21 @@ async def create_model_config(
|
||||
config = ModelConfig(id=generate_id(), **req.model_dump())
|
||||
db.add(config)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"创建模型配置: {req.name}",
|
||||
"POST",
|
||||
"/admin/model-configs",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"config_id": config.id,
|
||||
"name": req.name,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@@ -1133,6 +1523,21 @@ async def update_model_config(
|
||||
for k, v in req.model_dump().items():
|
||||
setattr(config, k, v)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"更新模型配置: {config.name}",
|
||||
"PUT",
|
||||
f"/admin/model-configs/{config_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"config_id": config_id,
|
||||
"name": config.name,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@@ -1148,6 +1553,21 @@ async def delete_model_config(
|
||||
raise HTTPException(status_code=404, detail="配置不存在")
|
||||
await db.delete(config)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"删除模型配置: {config.name}",
|
||||
"DELETE",
|
||||
f"/admin/model-configs/{config_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"config_id": config_id,
|
||||
"name": config.name,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@@ -1175,6 +1595,22 @@ async def update_system_config(
|
||||
raise HTTPException(status_code=404, detail="配置不存在")
|
||||
config.value = str(req.value)
|
||||
await db.commit()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"更新系统配置: {config.key}",
|
||||
"PUT",
|
||||
f"/admin/system-configs/{config_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"config_id": config_id,
|
||||
"key": config.key,
|
||||
"value": req.value,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@@ -1537,6 +1973,21 @@ async def admin_update_generation_status(
|
||||
if new_status == "completed":
|
||||
record.generated_at = datetime.now()
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"更新生成记录状态: {new_status}",
|
||||
"PUT",
|
||||
f"/admin/generation-records/{record_id}/status",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"record_id": record_id,
|
||||
"new_status": new_status,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@@ -1668,6 +2119,22 @@ async def admin_generate_video(
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"管理员触发生成{type_str}: {record_id}",
|
||||
"POST",
|
||||
f"/admin/generation-records/{record_id}/generate",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"record_id": record_id,
|
||||
"gen_type": record.gen_type,
|
||||
"project_name": project_name,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok", "record_id": record_id}
|
||||
|
||||
|
||||
@@ -1719,6 +2186,22 @@ async def upload_pdf(
|
||||
value=url,
|
||||
))
|
||||
await db.commit()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"上传PDF文件: {file.filename}",
|
||||
"POST",
|
||||
"/admin/upload-pdf",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"filename": file.filename,
|
||||
"config_key": config_key,
|
||||
"url": url,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
|
||||
return {"url": url}
|
||||
|
||||
@@ -1765,6 +2248,21 @@ async def upload_logo(
|
||||
description="网站Logo图片",
|
||||
))
|
||||
await db.commit()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"上传Logo图片: {file.filename}",
|
||||
"POST",
|
||||
"/admin/upload-logo",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"filename": file.filename,
|
||||
"url": url,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ from app.services.resource_accounting_service import (
|
||||
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
|
||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
from app.services.resource_capacity_service import assert_user_resource_capacity_available
|
||||
from app.services.upload_resource import delete_unbound_upload_resource, upload_reference_file, cleanup_upload_resource_files_after_commit
|
||||
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
|
||||
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceTypeEnum
|
||||
from app.services.generation_billing_service import (
|
||||
CHARGE_TEXT_PROMPT,
|
||||
OWNER_GENERATION_RECORD,
|
||||
@@ -780,128 +783,188 @@ async def seedance_callback(request: Request, db: AsyncSession = Depends(get_db)
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@router.post("/upload-image")
|
||||
@router.post(
|
||||
"/upload-image",
|
||||
summary="上传 AI 创作普通参考图片",
|
||||
description=(
|
||||
"上传普通 AI 创作参考图片,写入 UploadResource 资源账本并纳入用户上传容量统计。"
|
||||
"返回 resource_id 和 url。该文件在未绑定业务记录前可通过 /generation-records/delete-file 单独删除,"
|
||||
"也会出现在 /upload-resources/history 历史素材中供 AI 创作复用。"
|
||||
),
|
||||
responses={400: {"description": "文件类型、大小或容量校验失败"}, 401: {"description": "未登录或 Token 无效"}},
|
||||
)
|
||||
async def upload_image(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
gen_type: str = Query("video", description="生成类型:video-视频,image-图片"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Upload an image for generation reference."""
|
||||
import os
|
||||
import uuid
|
||||
from app.config import settings
|
||||
from datetime import datetime
|
||||
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="仅支持图片文件")
|
||||
|
||||
ext = os.path.splitext(file.filename or ".png")[1] or ".png"
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe_name = f"{gen_type}_img_{current_user.id}_{timestamp}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
date_dir = datetime.now().strftime("%Y/%m/%d")
|
||||
dir_path = os.path.join(settings.UPLOAD_LOCAL_PATH, "images", date_dir)
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
file_path = os.path.join(dir_path, safe_name)
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > 10 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="图片大小不能超过10MB")
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
url = f"/uploads/images/{date_dir}/{safe_name}"
|
||||
return {"url": url, "filename": file.filename or safe_name, "type": "image", "gen_type": gen_type}
|
||||
"""上传普通参考图片,记录 UploadResource 并纳入用户容量统计。"""
|
||||
result = await upload_reference_file(
|
||||
db,
|
||||
file=file,
|
||||
current_user=current_user,
|
||||
module=UploadResourceModuleEnum.COMMON.value,
|
||||
resource_type=UploadResourceTypeEnum.IMAGE.value,
|
||||
gen_type=gen_type,
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"url": result.url,
|
||||
"filename": result.filename,
|
||||
"type": "image",
|
||||
"gen_type": gen_type,
|
||||
"resource_id": result.resource_id,
|
||||
"file_size_bytes": result.file_size_bytes,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/upload-video")
|
||||
@router.post(
|
||||
"/upload-video",
|
||||
summary="上传 AI 创作普通参考视频",
|
||||
description=(
|
||||
"上传普通 AI 创作参考视频,写入 UploadResource 资源账本并纳入用户上传容量统计。"
|
||||
"duration_seconds 为前端识别的视频秒数,用于历史复用和 AI 创作视频总时长校验。"
|
||||
"未绑定业务记录前可单独删除,并会出现在 /upload-resources/history 历史素材中。"
|
||||
),
|
||||
responses={400: {"description": "文件类型、大小、容量或视频参数校验失败"}, 401: {"description": "未登录或 Token 无效"}},
|
||||
)
|
||||
async def upload_video(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
duration_seconds: float | None = Query(None, description="前端识别的视频时长秒数,可选"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Upload a video for generation reference."""
|
||||
import os
|
||||
import uuid
|
||||
from app.config import settings
|
||||
from datetime import datetime
|
||||
|
||||
if not file.content_type or not file.content_type.startswith("video/"):
|
||||
raise HTTPException(status_code=400, detail="仅支持视频文件")
|
||||
|
||||
ext = os.path.splitext(file.filename or ".mp4")[1] or ".mp4"
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe_name = f"video_ref_{current_user.id}_{timestamp}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
date_dir = datetime.now().strftime("%Y/%m/%d")
|
||||
dir_path = os.path.join(settings.UPLOAD_LOCAL_PATH, "videos", date_dir)
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
file_path = os.path.join(dir_path, safe_name)
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > 100 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="视频大小不能超过100MB")
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
url = f"/uploads/videos/{date_dir}/{safe_name}"
|
||||
return {"url": url, "filename": file.filename or safe_name, "type": "video"}
|
||||
"""上传普通参考视频,记录 UploadResource 并纳入用户容量统计。"""
|
||||
result = await upload_reference_file(
|
||||
db,
|
||||
file=file,
|
||||
current_user=current_user,
|
||||
module=UploadResourceModuleEnum.COMMON.value,
|
||||
resource_type=UploadResourceTypeEnum.VIDEO.value,
|
||||
duration_seconds=duration_seconds,
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"url": result.url,
|
||||
"filename": result.filename,
|
||||
"type": "video",
|
||||
"resource_id": result.resource_id,
|
||||
"file_size_bytes": result.file_size_bytes,
|
||||
"duration_seconds": result.duration_seconds,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/upload-audio")
|
||||
@router.post(
|
||||
"/upload-audio",
|
||||
summary="上传 AI 创作普通参考音频",
|
||||
description=(
|
||||
"上传普通 AI 创作参考音频,写入 UploadResource 资源账本并纳入用户上传容量统计。"
|
||||
"当前仅支持 mp3、wav;单文件大小受 AUDIO_MAX_FILE_SIZE_MB 限制。"
|
||||
"duration_seconds 为前端识别的音频秒数,用于 AI 创作音频总时长校验。"
|
||||
"未绑定业务记录前可单独删除,并会出现在 /upload-resources/history 历史素材中。"
|
||||
),
|
||||
responses={400: {"description": "音频格式、MIME、大小或容量校验失败"}, 401: {"description": "未登录或 Token 无效"}},
|
||||
)
|
||||
async def upload_audio(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
duration_seconds: float | None = Query(None, description="前端识别的音频时长秒数,可选"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Upload an audio file for AI creation reference."""
|
||||
"""上传普通参考音频,记录 UploadResource 并纳入用户容量统计。"""
|
||||
import os
|
||||
import uuid
|
||||
from app.config import settings
|
||||
from datetime import datetime
|
||||
|
||||
ext = os.path.splitext(file.filename or "")[1].lower().lstrip(".")
|
||||
if ext not in AUDIO_ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(status_code=400, detail="仅支持 mp3、wav 音频文件")
|
||||
|
||||
expected_mime = AUDIO_ALLOWED_MIME_TYPES.get(ext)
|
||||
if not file.content_type or file.content_type != expected_mime:
|
||||
if expected_mime and file.content_type and file.content_type != expected_mime:
|
||||
raise HTTPException(status_code=400, detail=f"音频 MIME 类型错误,{ext} 必须为 {expected_mime}")
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe_name = f"audio_ref_{current_user.id}_{timestamp}_{uuid.uuid4().hex[:8]}.{ext}"
|
||||
date_dir = datetime.now().strftime("%Y/%m/%d")
|
||||
dir_path = os.path.join(settings.UPLOAD_LOCAL_PATH, "audios", date_dir)
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
file_path = os.path.join(dir_path, safe_name)
|
||||
|
||||
content = await file.read()
|
||||
max_bytes = AUDIO_MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
if len(content) > max_bytes:
|
||||
raise HTTPException(status_code=400, detail=f"音频大小不能超过{AUDIO_MAX_FILE_SIZE_MB}MB")
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
url = f"/uploads/audios/{date_dir}/{safe_name}"
|
||||
return {"url": url, "filename": file.filename or safe_name, "type": "audio"}
|
||||
result = await upload_reference_file(
|
||||
db,
|
||||
file=file,
|
||||
current_user=current_user,
|
||||
module=UploadResourceModuleEnum.COMMON.value,
|
||||
resource_type=UploadResourceTypeEnum.AUDIO.value,
|
||||
duration_seconds=duration_seconds,
|
||||
max_bytes=AUDIO_MAX_FILE_SIZE_MB * 1024 * 1024,
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"url": result.url,
|
||||
"filename": result.filename,
|
||||
"type": "audio",
|
||||
"resource_id": result.resource_id,
|
||||
"file_size_bytes": result.file_size_bytes,
|
||||
"duration_seconds": result.duration_seconds,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/delete-file")
|
||||
@router.post(
|
||||
"/delete-file",
|
||||
summary="删除未绑定上传文件",
|
||||
description=(
|
||||
"删除当前用户自己的未绑定上传文件,并释放 UploadResource 上传容量。"
|
||||
"仅允许删除 bind_status=pending、delete_policy=user_deletable、未绑定 source_model/source_id 的资源。"
|
||||
"删除顺序为主事务先 soft delete 并 commit,commit 成功后再清理真实文件。"
|
||||
"该接口保留给单文件删除;批量删除请使用 DELETE /upload-resources/history/batch。"
|
||||
),
|
||||
responses={400: {"description": "文件路径无效、文件已被模块任务使用或不可单独删除"}, 401: {"description": "未登录或 Token 无效"}, 403: {"description": "无权删除此文件"}},
|
||||
)
|
||||
async def delete_upload(
|
||||
url: str = Query(..., description="文件URL,如 /uploads/images/2024/01/01/video_img_xxx.png"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete an uploaded file by URL."""
|
||||
import os
|
||||
from app.config import settings
|
||||
"""删除未绑定业务记录的上传文件,并释放 UploadResource 容量。"""
|
||||
user_id = str(current_user.id)
|
||||
pending_ids: list[str] = []
|
||||
legacy_paths: list[str] = []
|
||||
try:
|
||||
result = await delete_unbound_upload_resource(db, user=current_user, url=url)
|
||||
pending_ids = list(result.pop("_pending_physical_delete_resource_ids", []) or [])
|
||||
legacy_paths = list(result.pop("_legacy_pending_delete_paths", []) or [])
|
||||
await db.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await safe_rollback_with_log(
|
||||
db,
|
||||
event_type=UploadResourceEventEnum.DELETE_UPLOAD_ROLLBACK_FAILED.value,
|
||||
message="删除上传文件主事务回滚失败",
|
||||
user_id=user_id,
|
||||
detail={"url": url},
|
||||
original_exc=exc,
|
||||
)
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.DELETE_UPLOAD_FAILED.value,
|
||||
message=f"删除上传文件失败: {exc}",
|
||||
user_id=user_id,
|
||||
detail={"url": url},
|
||||
exc=exc,
|
||||
)
|
||||
raise
|
||||
|
||||
if not url.startswith("/uploads/"):
|
||||
raise HTTPException(status_code=400, detail="无效的文件路径")
|
||||
|
||||
if current_user.id not in url:
|
||||
raise HTTPException(status_code=403, detail="无权删除此文件")
|
||||
|
||||
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, url.replace("/uploads/", ""))
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
return {"message": "ok"}
|
||||
if pending_ids or legacy_paths:
|
||||
try:
|
||||
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids, legacy_paths=legacy_paths)
|
||||
await db.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await safe_rollback_with_log(
|
||||
db,
|
||||
event_type=UploadResourceEventEnum.DELETE_UPLOAD_ROLLBACK_FAILED.value,
|
||||
message="删除上传文件 cleanup 事务回滚失败",
|
||||
user_id=user_id,
|
||||
detail={"url": url, "pending_ids": pending_ids, "legacy_paths": legacy_paths},
|
||||
original_exc=exc,
|
||||
)
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.DELETE_UPLOAD_CLEANUP_FAILED.value,
|
||||
message=f"删除上传文件后清理真实文件失败: {exc}",
|
||||
user_id=user_id,
|
||||
resource_ids=pending_ids,
|
||||
detail={"url": url, "legacy_paths": legacy_paths},
|
||||
exc=exc,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -121,6 +121,9 @@ async def list_engines(
|
||||
"该接口用于 Chat 风格的图片/视频生成,不再绑定 project_id。"
|
||||
"创建成功后会写入 chat_generation_tasks 表,并投递 Celery 异步任务。"
|
||||
"支持 image 图片生成和 video 视频生成。"
|
||||
"media_references 支持 image/video/audio;source 可为 upload_resource=历史上传素材、"
|
||||
"private_portrait_asset=私域真人/虚拟素材、空=本次普通上传素材。"
|
||||
"视频/音频参考素材必须携带 duration,并按 AI 创作原逻辑校验单段 2~15 秒、总时长不超过 15 秒。"
|
||||
"建议前端传入 idempotency_key,用于防止按钮连点、网络重试导致重复创建任务和重复扣费。"
|
||||
),
|
||||
responses={
|
||||
@@ -141,7 +144,12 @@ async def list_engines(
|
||||
async def create_task(
|
||||
req: GenerationAITaskCreate = Body(
|
||||
...,
|
||||
description="AI生成任务创建参数。gen_type=image 时使用图片参数;gen_type=video 时使用视频参数",
|
||||
description=(
|
||||
"AI生成任务创建参数。gen_type=image 时使用图片参数;gen_type=video 时使用视频参数。"
|
||||
"枚举:gen_type=image/video;media_references[].type=image/video/audio;"
|
||||
"media_references[].source=upload_resource/private_portrait_asset/空;"
|
||||
"media_references[].role=first_frame/last_frame/reference_image/reference_video/reference_audio。"
|
||||
),
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -186,7 +194,8 @@ async def create_task(
|
||||
"分页获取当前登录用户的AI生成任务列表。"
|
||||
"可按生成类型 gen_type 和任务状态 status 过滤。"
|
||||
"该接口返回的是普通任务列表,不按日期分组。"
|
||||
"如果前端需要按生成日期分组展示历史记录,请使用 /generation-ai/history 接口。"
|
||||
"如果前端需要按生成日期分组展示生成历史记录,请使用 /generation-ai/history 接口。"
|
||||
"上传素材历史不是生成历史,请使用 /upload-resources/history。"
|
||||
),
|
||||
responses={
|
||||
200: {
|
||||
|
||||
@@ -3,14 +3,14 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.enums.common import ModuleProjectStatusEnum
|
||||
from app.enums.hot_opening_replicate import HotOpeningStepCodeEnum, ModuleCodeEnum
|
||||
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningStepCodeEnum, ModuleCodeEnum
|
||||
from app.schemas.hot_opening_replicate import (
|
||||
HotOpeningActionOut,
|
||||
HotOpeningDeleteOut,
|
||||
@@ -48,6 +48,9 @@ from app.services.module_async_recovery_service import (
|
||||
register_module_step_task,
|
||||
)
|
||||
from app.tasks.celery_app import celery_app
|
||||
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
|
||||
from app.services.upload_resource import upload_reference_file, bind_upload_resources, cleanup_upload_resource_files_after_commit
|
||||
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
|
||||
|
||||
MODULE = ModuleCodeEnum.HOT_OPENING_REPLICATE.value
|
||||
|
||||
@@ -125,7 +128,7 @@ def _log_api_exception_from_locals(exc: BaseException, local_values: dict, messa
|
||||
req = local_values.get("req")
|
||||
detail = {"request": req.model_dump() if hasattr(req, "model_dump") else str(req) if req is not None else None}
|
||||
_log_api_error(
|
||||
event_type="API_REQUEST_FAILED",
|
||||
event_type=HotOpeningLogEventEnum.API_REQUEST_FAILED.value,
|
||||
current_user=current_user if isinstance(current_user, User) else None,
|
||||
project_id=str(project_id) if project_id else None,
|
||||
step_id=str(step_id) if step_id else None,
|
||||
@@ -172,7 +175,7 @@ async def _mark_dispatch_failed_and_raise(
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_error(
|
||||
event_type="CELERY_DISPATCH_MARK_FAILED",
|
||||
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
@@ -182,7 +185,7 @@ async def _mark_dispatch_failed_and_raise(
|
||||
)
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type="CELERY_DISPATCH_FAILED",
|
||||
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
@@ -203,6 +206,66 @@ async def get_spec():
|
||||
return HotOpeningSpecOut()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/upload-image",
|
||||
summary="上传爆款开头复刻图片素材",
|
||||
description="上传后先记录为 pending UploadResource;创建项目成功后绑定 ModuleGenerationProject。",
|
||||
)
|
||||
async def upload_hot_opening_image(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await upload_reference_file(
|
||||
db,
|
||||
file=file,
|
||||
current_user=current_user,
|
||||
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
|
||||
resource_type=UploadResourceTypeEnum.IMAGE.value,
|
||||
gen_type="video",
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"url": result.url,
|
||||
"filename": result.filename,
|
||||
"type": "image",
|
||||
"module": result.module,
|
||||
"resource_id": result.resource_id,
|
||||
"file_size_bytes": result.file_size_bytes,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/upload-video",
|
||||
summary="上传爆款开头复刻视频素材",
|
||||
description="上传后先记录为 pending UploadResource;创建项目成功后绑定 ModuleGenerationProject。",
|
||||
)
|
||||
async def upload_hot_opening_video(
|
||||
file: UploadFile = File(...),
|
||||
duration_seconds: float | None = Query(None, description="前端识别的视频时长秒数,可选"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await upload_reference_file(
|
||||
db,
|
||||
file=file,
|
||||
current_user=current_user,
|
||||
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
|
||||
resource_type=UploadResourceTypeEnum.VIDEO.value,
|
||||
duration_seconds=duration_seconds,
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"url": result.url,
|
||||
"filename": result.filename,
|
||||
"type": "video",
|
||||
"module": result.module,
|
||||
"resource_id": result.resource_id,
|
||||
"file_size_bytes": result.file_size_bytes,
|
||||
"duration_seconds": result.duration_seconds,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tasks",
|
||||
response_model=HotOpeningTaskDetailOut,
|
||||
@@ -222,6 +285,16 @@ async def create_task(
|
||||
try:
|
||||
project = await create_hot_opening_project(db, current_user, req)
|
||||
project_id_value = str(project.id)
|
||||
await bind_upload_resources(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
|
||||
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
|
||||
source_id=project_id_value,
|
||||
resource_ids=[req.material_video_resource_id, req.material_image_resource_id],
|
||||
urls=[req.material_video_url, req.material_image_url],
|
||||
allow_common_migrate=True,
|
||||
)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
@@ -439,7 +512,7 @@ async def generate_image_prompt(
|
||||
_ = req
|
||||
if celery_app is None:
|
||||
_log_api_error(
|
||||
event_type="CELERY_DISABLED",
|
||||
event_type=HotOpeningLogEventEnum.CELERY_DISABLED.value,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
@@ -509,7 +582,7 @@ async def generate_image(
|
||||
):
|
||||
if celery_app is None:
|
||||
_log_api_error(
|
||||
event_type="CELERY_DISABLED",
|
||||
event_type=HotOpeningLogEventEnum.CELERY_DISABLED.value,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
@@ -575,7 +648,7 @@ async def generate_video_prompt(
|
||||
):
|
||||
if celery_app is None:
|
||||
_log_api_error(
|
||||
event_type="CELERY_DISABLED",
|
||||
event_type=HotOpeningLogEventEnum.CELERY_DISABLED.value,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
@@ -646,7 +719,7 @@ async def generate_video(
|
||||
):
|
||||
if celery_app is None:
|
||||
_log_api_error(
|
||||
event_type="CELERY_DISABLED",
|
||||
event_type=HotOpeningLogEventEnum.CELERY_DISABLED.value,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
@@ -703,13 +776,72 @@ async def delete_task(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user_id = _safe_user_id(current_user)
|
||||
pending_ids: list[str] = []
|
||||
try:
|
||||
result = await delete_hot_opening_project(db, current_user=current_user, project_id=project_id)
|
||||
pending_ids = list(result.pending_delete_resource_ids or [])
|
||||
await db.commit()
|
||||
return result
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
except HTTPException as exc:
|
||||
await safe_rollback_with_log(
|
||||
db,
|
||||
event_type=UploadResourceEventEnum.HOT_OPENING_DELETE_PROJECT_ROLLBACK_FAILED.value,
|
||||
message="删除爆款开头复刻项目 HTTPException 回滚失败",
|
||||
user_id=user_id,
|
||||
module=MODULE,
|
||||
detail={"project_id": project_id},
|
||||
original_exc=exc,
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await safe_rollback_with_log(
|
||||
db,
|
||||
event_type=UploadResourceEventEnum.HOT_OPENING_DELETE_PROJECT_ROLLBACK_FAILED.value,
|
||||
message="删除爆款开头复刻项目主事务回滚失败",
|
||||
user_id=user_id,
|
||||
module=MODULE,
|
||||
detail={"project_id": project_id},
|
||||
original_exc=exc,
|
||||
)
|
||||
_log_api_error(
|
||||
event_type=HotOpeningLogEventEnum.API_REQUEST_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
message=f"删除爆款开头复刻项目失败: {exc}",
|
||||
detail={"project_id": project_id},
|
||||
exc=exc,
|
||||
)
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.HOT_OPENING_DELETE_PROJECT_FAILED.value,
|
||||
message=f"删除爆款开头复刻项目失败: {exc}",
|
||||
user_id=user_id,
|
||||
module=MODULE,
|
||||
detail={"project_id": project_id},
|
||||
exc=exc,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"删除爆款开头复刻项目失败: {exc}")
|
||||
|
||||
if pending_ids:
|
||||
try:
|
||||
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids)
|
||||
await db.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await safe_rollback_with_log(
|
||||
db,
|
||||
event_type=UploadResourceEventEnum.HOT_OPENING_DELETE_PROJECT_ROLLBACK_FAILED.value,
|
||||
message="删除爆款开头复刻项目 cleanup 事务回滚失败",
|
||||
user_id=user_id,
|
||||
module=MODULE,
|
||||
detail={"project_id": project_id, "pending_ids": pending_ids},
|
||||
original_exc=exc,
|
||||
)
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.HOT_OPENING_DELETE_PROJECT_CLEANUP_FAILED.value,
|
||||
message=f"删除爆款开头复刻项目后清理真实文件失败: {exc}",
|
||||
user_id=user_id,
|
||||
resource_ids=pending_ids,
|
||||
module=MODULE,
|
||||
detail={"project_id": project_id},
|
||||
exc=exc,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -2,11 +2,9 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, get_admin_user, get_backend_user, get_current_user
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.menu_config import MenuConfig
|
||||
from app.schemas.menu import MenuConfigCreate, MenuConfigOut
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
router = APIRouter(tags=["menu-configs"])
|
||||
|
||||
@@ -57,58 +55,3 @@ async def public_list_menu_configs(
|
||||
}
|
||||
for m in menus
|
||||
]
|
||||
|
||||
|
||||
@router.get("/admin/menu-configs", response_model=list[MenuConfigOut])
|
||||
async def admin_list_menu_configs(
|
||||
admin: User = Depends(get_backend_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(MenuConfig).order_by(MenuConfig.sort_order))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/admin/menu-configs", response_model=MenuConfigOut)
|
||||
async def create_menu_config(
|
||||
req: MenuConfigCreate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
menu = MenuConfig(id=generate_id(), **req.model_dump())
|
||||
db.add(menu)
|
||||
await db.flush()
|
||||
return menu
|
||||
|
||||
|
||||
@router.put("/admin/menu-configs/{menu_id}", response_model=MenuConfigOut)
|
||||
async def update_menu_config(
|
||||
menu_id: str,
|
||||
req: MenuConfigCreate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(MenuConfig).where(MenuConfig.id == menu_id).limit(1))
|
||||
menu = result.scalar_one_or_none()
|
||||
if not menu:
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="菜单不存在")
|
||||
for k, v in req.model_dump().items():
|
||||
setattr(menu, k, v)
|
||||
await db.flush()
|
||||
return menu
|
||||
|
||||
|
||||
@router.delete("/admin/menu-configs/{menu_id}")
|
||||
async def delete_menu_config(
|
||||
menu_id: str,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(MenuConfig).where(MenuConfig.id == menu_id).limit(1))
|
||||
menu = result.scalar_one_or_none()
|
||||
if not menu:
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="菜单不存在")
|
||||
await db.delete(menu)
|
||||
await db.flush()
|
||||
return {"message": "ok"}
|
||||
|
||||
@@ -9,7 +9,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.enums.private_portrait import (
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
@@ -23,8 +22,9 @@ from app.schemas.private_portrait import (
|
||||
PrivatePortraitAssetCreate,
|
||||
PrivatePortraitAssetListOut,
|
||||
PrivatePortraitAssetOut,
|
||||
PrivatePortraitDeleteOut,
|
||||
PrivatePortraitConfigOut,
|
||||
PrivatePortraitDeleteOut,
|
||||
PrivatePortraitEnumMetaOut,
|
||||
PrivatePortraitProjectCreate,
|
||||
PrivatePortraitProjectCreateWithValidateOut,
|
||||
PrivatePortraitProjectListOut,
|
||||
@@ -33,7 +33,6 @@ from app.schemas.private_portrait import (
|
||||
PrivatePortraitSelectableAssetListOut,
|
||||
PrivatePortraitValidateSessionCreate,
|
||||
PrivatePortraitValidateSessionOut,
|
||||
PrivatePortraitEnumMetaOut,
|
||||
build_private_portrait_enum_meta,
|
||||
)
|
||||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||||
@@ -65,20 +64,47 @@ from app.services.private_portrait.real_person.service import (
|
||||
|
||||
router = APIRouter(tags=["私域真人素材库"])
|
||||
|
||||
_REAL_PERSON_API_DESCRIPTION = (
|
||||
"私域真人素材库 API。真人和虚拟素材共用用户素材额度;user.private_portrait_asset_limit=0 表示关闭,"
|
||||
">0 表示启用并限制总素材数量。真人项目创建后需要通过火山 CreateVisualValidateSession 进行人脸认证,"
|
||||
"每个真人项目只允许认证一次。认证回调 resultCode=10000 表示成功,成功后才允许上传可用于 AI 创作的真人素材。"
|
||||
)
|
||||
|
||||
|
||||
def _log_task_dispatch_failed(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None, exc: BaseException) -> None:
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.TASK_DISPATCH_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, asset_id=asset_id, exc=exc, detail={"task_name": task_name})
|
||||
log_operation_error(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.TASK_DISPATCH_FAILED.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
asset_id=asset_id,
|
||||
exc=exc,
|
||||
detail={"task_name": task_name},
|
||||
)
|
||||
|
||||
|
||||
def _log_task_dispatch_success(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None) -> None:
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.TASK_DISPATCH_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, asset_id=asset_id, detail={"task_name": task_name})
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.TASK_DISPATCH_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
asset_id=asset_id,
|
||||
detail={"task_name": task_name},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/private-portrait/config",
|
||||
response_model=PrivatePortraitConfigOut,
|
||||
summary="获取当前用户私域人像素材额度配置",
|
||||
description="返回私域人像素材总量限制。额度由真人认证素材库与虚拟人像素材库共用,图片和视频共用,Audio 暂未开放。",
|
||||
summary="获取当前用户私域真人素材额度配置",
|
||||
description=(
|
||||
_REAL_PERSON_API_DESCRIPTION
|
||||
+ "返回 enabled、asset_limit、used_asset_count、remaining_asset_count 等字段。额度按用户维度限制,真人/虚拟、图片/视频共用;Audio 暂不开放。"
|
||||
),
|
||||
)
|
||||
async def get_my_private_portrait_config(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
return await get_user_private_portrait_config(db, user_id=current_user.id)
|
||||
@@ -87,8 +113,12 @@ async def get_my_private_portrait_config(current_user: User = Depends(get_curren
|
||||
@router.get(
|
||||
"/private-portrait/meta/enums",
|
||||
response_model=PrivatePortraitEnumMetaOut,
|
||||
summary="获取私域人像素材库枚举说明",
|
||||
description="给前端展示状态、类型、素材库类型使用。Audio 仅作为火山支持项展示,当前业务不开放上传。",
|
||||
summary="获取私域真人素材库枚举说明",
|
||||
description=(
|
||||
"返回前端展示所需枚举:library_type=real_person/aigc_virtual;"
|
||||
"asset_type=Image/Video/Audio;project status、asset status、remote_delete_status 等。"
|
||||
"当前业务上传只开放 Image 和 Video,Audio 仅作为兼容枚举展示。"
|
||||
),
|
||||
)
|
||||
async def get_private_portrait_enum_meta():
|
||||
return build_private_portrait_enum_meta()
|
||||
@@ -98,7 +128,11 @@ async def get_private_portrait_enum_meta():
|
||||
"/private-portrait/projects",
|
||||
response_model=PrivatePortraitProjectCreateWithValidateOut,
|
||||
summary="创建真人认证素材项目并生成认证会话",
|
||||
description="创建本地真人素材项目,随后调用火山 CreateVisualValidateSession 返回 H5Link。用户完成认证后,回调会创建本地 Asset Group 映射。",
|
||||
description=(
|
||||
_REAL_PERSON_API_DESCRIPTION
|
||||
+ "创建本地真人项目后立即调用火山 CreateVisualValidateSession,返回 H5Link/BytedToken 给 PC 前端展示二维码。"
|
||||
"手机扫码完成人脸认证后,PC 端通过 validate_session 查询状态;认证成功后才允许创建真人素材。"
|
||||
),
|
||||
)
|
||||
async def create_private_portrait_project(payload: PrivatePortraitProjectCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
project = await create_real_person_project(db, user_id=current_user.id, payload=payload)
|
||||
@@ -112,13 +146,13 @@ async def create_private_portrait_project(payload: PrivatePortraitProjectCreate,
|
||||
"/private-portrait/projects",
|
||||
response_model=PrivatePortraitProjectListOut,
|
||||
summary="查询当前用户真人认证素材项目列表",
|
||||
description="只返回 library_type=real_person 的项目。默认查询 active 项目,可用 status 覆盖。",
|
||||
description="只返回 library_type=real_person 的项目。默认查询 active 项目,可通过 status 覆盖。",
|
||||
)
|
||||
async def list_private_portrait_projects(
|
||||
page: int = Query(1, ge=1, description="页码,从 1 开始。"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100。"),
|
||||
keyword: str | None = Query(None, description="项目名称模糊搜索。"),
|
||||
status: str | None = Query(None, description="项目状态,不传默认 active。"),
|
||||
page: int = Query(1, ge=1, description="页码,从 1 开始"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100"),
|
||||
keyword: str | None = Query(None, description="项目名称模糊搜索"),
|
||||
status: str | None = Query(None, description="项目状态筛选,不传默认 active"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -130,12 +164,22 @@ async def list_private_portrait_projects(
|
||||
return PrivatePortraitProjectListOut(items=[project_to_out(item) for item in items], total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="获取真人认证素材项目详情")
|
||||
@router.get(
|
||||
"/private-portrait/projects/{project_id}",
|
||||
response_model=PrivatePortraitProjectOut,
|
||||
summary="获取真人认证素材项目详情",
|
||||
description="获取当前用户真人项目详情,project_id 必须属于当前用户且 library_type=real_person。",
|
||||
)
|
||||
async def get_private_portrait_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
return project_to_out(await get_user_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value))
|
||||
|
||||
|
||||
@router.put("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="更新真人认证素材项目")
|
||||
@router.put(
|
||||
"/private-portrait/projects/{project_id}",
|
||||
response_model=PrivatePortraitProjectOut,
|
||||
summary="更新真人认证素材项目",
|
||||
description="更新真人项目本地展示信息;不会重新发起真人认证。每个真人项目只允许认证一次。",
|
||||
)
|
||||
async def update_private_portrait_project(project_id: str, payload: PrivatePortraitProjectUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
project = await update_real_person_project(db, user_id=current_user.id, project_id=project_id, payload=payload)
|
||||
out = project_to_out(project)
|
||||
@@ -143,7 +187,12 @@ async def update_private_portrait_project(project_id: str, payload: PrivatePortr
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/private-portrait/projects/{project_id}", response_model=PrivatePortraitDeleteOut, summary="删除真人认证素材项目")
|
||||
@router.delete(
|
||||
"/private-portrait/projects/{project_id}",
|
||||
response_model=PrivatePortraitDeleteOut,
|
||||
summary="删除真人认证素材项目",
|
||||
description="软删真人项目和本地素材记录。本地先 commit,commit 成功后投递 Celery 删除远端 AssetGroup/Asset;remote_delete_status=pending 表示远端删除处理中。",
|
||||
)
|
||||
async def delete_private_portrait_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
project = await soft_delete_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
project_id_snapshot = project.id
|
||||
@@ -158,7 +207,12 @@ async def delete_private_portrait_project(project_id: str, current_user: User =
|
||||
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||
|
||||
|
||||
@router.post("/private-portrait/projects/{project_id}/validate-sessions", response_model=PrivatePortraitValidateSessionOut, summary="重新创建真人认证会话")
|
||||
@router.post(
|
||||
"/private-portrait/projects/{project_id}/validate-sessions",
|
||||
response_model=PrivatePortraitValidateSessionOut,
|
||||
summary="重新创建真人认证会话",
|
||||
description="为真人项目创建新的认证会话。业务层会限制项目只能认证一次;已认证成功的项目不允许重复认证。",
|
||||
)
|
||||
async def create_private_portrait_validate_session(project_id: str, payload: PrivatePortraitValidateSessionCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
session = await create_real_person_validate_session(db, user_id=current_user.id, project_id=project_id, callback_redirect_url=payload.callback_redirect_url)
|
||||
out = validate_session_to_out(session)
|
||||
@@ -166,12 +220,21 @@ async def create_private_portrait_validate_session(project_id: str, payload: Pri
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/private-portrait/validate-sessions/{session_id}", response_model=PrivatePortraitValidateSessionOut, summary="查询真人认证会话状态")
|
||||
@router.get(
|
||||
"/private-portrait/validate-sessions/{session_id}",
|
||||
response_model=PrivatePortraitValidateSessionOut,
|
||||
summary="查询真人认证会话状态",
|
||||
description="PC 端轮询该接口查看手机扫码认证结果。status/result_code/remote_group_id 可用于判断是否认证成功并提示用户回到 PC 查看项目。",
|
||||
)
|
||||
async def get_private_portrait_validate_session(session_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
return validate_session_to_out(await get_validate_session(db, user_id=current_user.id, session_id=session_id))
|
||||
|
||||
|
||||
@router.get("/private-portrait/validate-callback", summary="火山真人认证回调入口")
|
||||
@router.get(
|
||||
"/private-portrait/validate-callback",
|
||||
summary="火山真人认证回调入口",
|
||||
description="火山真人认证 H5 回调入口。resultCode=10000 表示认证成功;成功后会创建或更新本地 AssetGroup 映射,并可 redirect 回前端提示页。",
|
||||
)
|
||||
async def private_portrait_validate_callback(session_id: str, request: Request, redirect_url: str | None = None, db: AsyncSession = Depends(get_db)):
|
||||
params = dict(request.query_params)
|
||||
params.pop("session_id", None)
|
||||
@@ -189,7 +252,16 @@ async def private_portrait_validate_callback(session_id: str, request: Request,
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetOut, summary="上传真人认证素材", description="当前支持 Image / Video。Audio 暂不开放。CreateAsset 是异步接口,返回后需要轮询到 Active 才可用于生成。")
|
||||
@router.post(
|
||||
"/private-portrait/projects/{project_id}/assets",
|
||||
response_model=PrivatePortraitAssetOut,
|
||||
summary="上传真人认证素材",
|
||||
description=(
|
||||
"在已认证成功的真人项目下创建素材。当前仅开放 asset_type=Image/Video,Audio 暂不开放。"
|
||||
"Video 必须携带 video_duration,建议前端限制 2~15 秒。CreateAsset 是异步接口,返回后会投递轮询任务,"
|
||||
"只有 status=Active 的素材才会出现在 selectable-assets 并可用于 AI 创作。"
|
||||
),
|
||||
)
|
||||
async def create_private_portrait_asset(project_id: str, payload: PrivatePortraitAssetCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
asset = await create_real_person_asset(db, user_id=current_user.id, project_id=project_id, payload=payload)
|
||||
asset_id_snapshot = asset.id
|
||||
@@ -206,13 +278,32 @@ async def create_private_portrait_asset(project_id: str, payload: PrivatePortrai
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut, summary="查询真人认证素材列表")
|
||||
async def list_private_portrait_assets(project_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = Query(None), keyword: str | None = Query(None), asset_type: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
@router.get(
|
||||
"/private-portrait/projects/{project_id}/assets",
|
||||
response_model=PrivatePortraitAssetListOut,
|
||||
summary="查询真人认证素材列表",
|
||||
description="查询指定真人项目下的素材。asset_type 可传 Image 或 Video;status 可筛选素材状态。",
|
||||
)
|
||||
async def list_private_portrait_assets(
|
||||
project_id: str,
|
||||
page: int = Query(1, ge=1, description="页码,从 1 开始"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100"),
|
||||
status: str | None = Query(None, description="素材状态筛选,不传查全部"),
|
||||
keyword: str | None = Query(None, description="素材名称模糊搜索"),
|
||||
asset_type: str | None = Query(None, description="素材类型:Image=图片,Video=视频;Audio 暂不开放"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
assets, total, project_name_map = await list_assets(db, user_id=current_user.id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.REAL_PERSON.value, asset_type=asset_type)
|
||||
return PrivatePortraitAssetListOut(items=[asset_to_out(asset, project_name=project_name_map.get(asset.project_id)) for asset in assets], total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/private-portrait/assets/{asset_id}", summary="获取真人认证素材详情")
|
||||
@router.get(
|
||||
"/private-portrait/assets/{asset_id}",
|
||||
response_model=PrivatePortraitAssetOut,
|
||||
summary="获取真人认证素材详情",
|
||||
description="获取当前用户真人素材详情,asset_id 必须属于当前用户且 library_type=real_person。",
|
||||
)
|
||||
async def get_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == current_user.id, PrivatePortraitAsset.library_type == PrivatePortraitLibraryType.REAL_PERSON.value).limit(1))).scalar_one_or_none()
|
||||
if not asset:
|
||||
@@ -221,7 +312,12 @@ async def get_private_portrait_asset(asset_id: str, current_user: User = Depends
|
||||
return asset_to_out(asset, project_name=project.name if project else None)
|
||||
|
||||
|
||||
@router.post("/private-portrait/assets/{asset_id}/sync", summary="同步真人认证素材状态")
|
||||
@router.post(
|
||||
"/private-portrait/assets/{asset_id}/sync",
|
||||
response_model=PrivatePortraitAssetOut,
|
||||
summary="同步真人认证素材状态",
|
||||
description="主动向火山查询并同步真人素材状态。一般由轮询任务自动执行;前端排查或手动刷新时可调用。",
|
||||
)
|
||||
async def sync_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
asset = await sync_asset_status(db, user_id=current_user.id, asset_id=asset_id)
|
||||
if asset.library_type != PrivatePortraitLibraryType.REAL_PERSON.value:
|
||||
@@ -231,7 +327,12 @@ async def sync_private_portrait_asset(asset_id: str, current_user: User = Depend
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/private-portrait/assets/{asset_id}", response_model=PrivatePortraitDeleteOut, summary="删除真人认证素材")
|
||||
@router.delete(
|
||||
"/private-portrait/assets/{asset_id}",
|
||||
response_model=PrivatePortraitDeleteOut,
|
||||
summary="删除真人认证素材",
|
||||
description="软删本地真人素材记录。本地先 commit,commit 成功后投递 Celery 删除远端 Asset;remote_delete_status=pending 表示远端删除处理中。",
|
||||
)
|
||||
async def delete_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
asset = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
asset_id_snapshot = asset.id
|
||||
@@ -247,7 +348,20 @@ async def delete_private_portrait_asset(asset_id: str, current_user: User = Depe
|
||||
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||
|
||||
|
||||
@router.get("/private-portrait/selectable-assets", response_model=PrivatePortraitSelectableAssetListOut, summary="查询可用于生成的真人认证素材")
|
||||
async def list_private_portrait_selectable_assets(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), project_id: str | None = Query(None), keyword: str | None = Query(None), asset_type: str | None = Query(None, description="Image 或 Video,不传查全部。"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
@router.get(
|
||||
"/private-portrait/selectable-assets",
|
||||
response_model=PrivatePortraitSelectableAssetListOut,
|
||||
summary="查询可用于 AI 创作的真人认证素材",
|
||||
description="只返回当前用户真人素材库中 status=Active 且未删除的 Image/Video 素材。该接口给 AI 创作参考内容选择器使用。",
|
||||
)
|
||||
async def list_private_portrait_selectable_assets(
|
||||
page: int = Query(1, ge=1, description="页码,从 1 开始"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100"),
|
||||
project_id: str | None = Query(None, description="按真人项目 ID 筛选"),
|
||||
keyword: str | None = Query(None, description="素材名称模糊搜索"),
|
||||
asset_type: str | None = Query(None, description="素材类型:Image 或 Video,不传查全部"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
items, total = await list_selectable_assets(db, user_id=current_user.id, project_id=project_id, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.REAL_PERSON.value, asset_type=asset_type)
|
||||
return PrivatePortraitSelectableAssetListOut(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
@@ -50,26 +50,65 @@ from app.services.private_portrait.virtual.service import create_virtual_asset,
|
||||
|
||||
router = APIRouter(tags=["私域虚拟人像素材库"])
|
||||
|
||||
_VIRTUAL_API_DESCRIPTION = (
|
||||
"私域虚拟素材库 API。真人和虚拟素材共用用户素材额度;user.private_portrait_asset_limit=0 表示关闭,"
|
||||
">0 表示启用并限制总素材数量。创建虚拟项目会同步调用火山 CreateAssetGroup,GroupType=AIGC,"
|
||||
"remote_project_name 默认使用 default。当前上传只开放 Image/Video,Audio 暂不开放。"
|
||||
)
|
||||
|
||||
|
||||
def _log_task_dispatch_failed(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None, exc: BaseException) -> None:
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.TASK_DISPATCH_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, asset_id=asset_id, exc=exc, detail={"task_name": task_name})
|
||||
log_operation_error(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.TASK_DISPATCH_FAILED.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
asset_id=asset_id,
|
||||
exc=exc,
|
||||
detail={"task_name": task_name},
|
||||
)
|
||||
|
||||
|
||||
def _log_task_dispatch_success(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None) -> None:
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.TASK_DISPATCH_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, asset_id=asset_id, detail={"task_name": task_name})
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.TASK_DISPATCH_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
asset_id=asset_id,
|
||||
detail={"task_name": task_name},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/private-portrait/virtual/config", response_model=PrivatePortraitConfigOut, summary="获取虚拟人像素材库额度配置", description="额度与真人认证素材库共用;图片/视频共用;Audio 暂不开放。")
|
||||
@router.get(
|
||||
"/private-portrait/virtual/config",
|
||||
response_model=PrivatePortraitConfigOut,
|
||||
summary="获取虚拟素材库额度配置",
|
||||
description=_VIRTUAL_API_DESCRIPTION + "返回虚拟素材库可用额度,实际与真人素材库共用。",
|
||||
)
|
||||
async def get_my_virtual_private_portrait_config(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
return await get_user_private_portrait_config(db, user_id=current_user.id)
|
||||
|
||||
|
||||
@router.get("/private-portrait/virtual-meta/enums", response_model=PrivatePortraitEnumMetaOut, summary="获取虚拟人像素材库枚举说明")
|
||||
@router.get(
|
||||
"/private-portrait/virtual-meta/enums",
|
||||
response_model=PrivatePortraitEnumMetaOut,
|
||||
summary="获取虚拟素材库枚举说明",
|
||||
description="返回前端展示所需枚举:library_type、asset_type、project status、asset status、remote_delete_status 等。Audio 暂不开放上传。",
|
||||
)
|
||||
async def get_virtual_private_portrait_enum_meta():
|
||||
return build_private_portrait_enum_meta()
|
||||
|
||||
|
||||
@router.post("/private-portrait/virtual-projects", response_model=PrivatePortraitProjectOut, summary="创建虚拟人像项目组", description="创建本地虚拟人像项目,并同步调用火山 CreateAssetGroup,GroupType=AIGC。ProjectName 必须与后续生成 API Key 所属项目一致,默认使用 default。")
|
||||
@router.post(
|
||||
"/private-portrait/virtual-projects",
|
||||
response_model=PrivatePortraitProjectOut,
|
||||
summary="创建虚拟素材项目组",
|
||||
description=_VIRTUAL_API_DESCRIPTION + "创建成功后返回本地项目详情,后续上传虚拟素材必须归属到该 project_id。",
|
||||
)
|
||||
async def create_private_portrait_virtual_project(payload: PrivatePortraitVirtualProjectCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
project = await create_virtual_project(db, user_id=current_user.id, payload=payload)
|
||||
out = project_to_out(project)
|
||||
@@ -77,8 +116,20 @@ async def create_private_portrait_virtual_project(payload: PrivatePortraitVirtua
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/private-portrait/virtual-projects", response_model=PrivatePortraitProjectListOut, summary="查询当前用户虚拟人像项目列表")
|
||||
async def list_private_portrait_virtual_projects(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), keyword: str | None = Query(None), status: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
@router.get(
|
||||
"/private-portrait/virtual-projects",
|
||||
response_model=PrivatePortraitProjectListOut,
|
||||
summary="查询当前用户虚拟素材项目列表",
|
||||
description="只返回 library_type=aigc_virtual 的项目。默认查询 active 项目,可通过 status 覆盖。",
|
||||
)
|
||||
async def list_private_portrait_virtual_projects(
|
||||
page: int = Query(1, ge=1, description="页码,从 1 开始"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100"),
|
||||
keyword: str | None = Query(None, description="项目名称模糊搜索"),
|
||||
status: str | None = Query(None, description="项目状态筛选,不传默认 active"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query_status = status or PrivatePortraitProjectStatus.ACTIVE.value
|
||||
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
await refresh_project_counters(db, [item.id for item in items])
|
||||
@@ -87,12 +138,22 @@ async def list_private_portrait_virtual_projects(page: int = Query(1, ge=1), pag
|
||||
return PrivatePortraitProjectListOut(items=[project_to_out(item) for item in items], total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/private-portrait/virtual-projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="获取虚拟人像项目详情")
|
||||
@router.get(
|
||||
"/private-portrait/virtual-projects/{project_id}",
|
||||
response_model=PrivatePortraitProjectOut,
|
||||
summary="获取虚拟素材项目详情",
|
||||
description="获取当前用户虚拟素材项目详情,project_id 必须属于当前用户且 library_type=aigc_virtual。",
|
||||
)
|
||||
async def get_private_portrait_virtual_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
return project_to_out(await get_user_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value))
|
||||
|
||||
|
||||
@router.put("/private-portrait/virtual-projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="更新虚拟人像项目")
|
||||
@router.put(
|
||||
"/private-portrait/virtual-projects/{project_id}",
|
||||
response_model=PrivatePortraitProjectOut,
|
||||
summary="更新虚拟素材项目",
|
||||
description="更新虚拟素材项目本地展示信息。不会重新创建远端 AssetGroup。",
|
||||
)
|
||||
async def update_private_portrait_virtual_project(project_id: str, payload: PrivatePortraitProjectUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
project = await update_virtual_project(db, user_id=current_user.id, project_id=project_id, payload=payload)
|
||||
out = project_to_out(project)
|
||||
@@ -100,7 +161,12 @@ async def update_private_portrait_virtual_project(project_id: str, payload: Priv
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/private-portrait/virtual-projects/{project_id}", response_model=PrivatePortraitDeleteOut, summary="删除虚拟人像项目")
|
||||
@router.delete(
|
||||
"/private-portrait/virtual-projects/{project_id}",
|
||||
response_model=PrivatePortraitDeleteOut,
|
||||
summary="删除虚拟素材项目",
|
||||
description="软删虚拟素材项目和本地素材记录。本地先 commit,commit 成功后投递 Celery 删除远端 AssetGroup/Asset;remote_delete_status=pending 表示远端删除处理中。",
|
||||
)
|
||||
async def delete_private_portrait_virtual_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
project = await soft_delete_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
project_id_snapshot = project.id
|
||||
@@ -115,7 +181,16 @@ async def delete_private_portrait_virtual_project(project_id: str, current_user:
|
||||
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||
|
||||
|
||||
@router.post("/private-portrait/virtual-projects/{project_id}/assets", response_model=PrivatePortraitAssetOut, summary="上传虚拟人像素材", description="当前支持 Image / Video。Audio 暂不开放。CreateAsset 是异步接口,返回后需要轮询到 Active 才可用于生成。")
|
||||
@router.post(
|
||||
"/private-portrait/virtual-projects/{project_id}/assets",
|
||||
response_model=PrivatePortraitAssetOut,
|
||||
summary="上传虚拟素材",
|
||||
description=(
|
||||
"在虚拟素材项目下创建素材。当前仅开放 asset_type=Image/Video,Audio 暂不开放。"
|
||||
"Video 必须携带 video_duration,建议前端限制 2~15 秒。CreateAsset 是异步接口,返回后会投递轮询任务,"
|
||||
"只有 status=Active 的素材才会出现在 virtual-selectable-assets 并可用于 AI 创作。"
|
||||
),
|
||||
)
|
||||
async def create_private_portrait_virtual_asset(project_id: str, payload: PrivatePortraitAssetCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
asset = await create_virtual_asset(db, user_id=current_user.id, project_id=project_id, payload=payload)
|
||||
asset_id_snapshot = asset.id
|
||||
@@ -132,13 +207,32 @@ async def create_private_portrait_virtual_asset(project_id: str, payload: Privat
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/private-portrait/virtual-projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut, summary="查询虚拟人像素材列表")
|
||||
async def list_private_portrait_virtual_assets(project_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = Query(None), keyword: str | None = Query(None), asset_type: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
@router.get(
|
||||
"/private-portrait/virtual-projects/{project_id}/assets",
|
||||
response_model=PrivatePortraitAssetListOut,
|
||||
summary="查询虚拟素材列表",
|
||||
description="查询指定虚拟项目下的素材。asset_type 可传 Image 或 Video;status 可筛选素材状态。",
|
||||
)
|
||||
async def list_private_portrait_virtual_assets(
|
||||
project_id: str,
|
||||
page: int = Query(1, ge=1, description="页码,从 1 开始"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100"),
|
||||
status: str | None = Query(None, description="素材状态筛选,不传查全部"),
|
||||
keyword: str | None = Query(None, description="素材名称模糊搜索"),
|
||||
asset_type: str | None = Query(None, description="素材类型:Image=图片,Video=视频;Audio 暂不开放"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
assets, total, project_name_map = await list_assets(db, user_id=current_user.id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, asset_type=asset_type)
|
||||
return PrivatePortraitAssetListOut(items=[asset_to_out(asset, project_name=project_name_map.get(asset.project_id)) for asset in assets], total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/private-portrait/virtual-assets/{asset_id}", response_model=PrivatePortraitAssetOut, summary="获取虚拟人像素材详情")
|
||||
@router.get(
|
||||
"/private-portrait/virtual-assets/{asset_id}",
|
||||
response_model=PrivatePortraitAssetOut,
|
||||
summary="获取虚拟素材详情",
|
||||
description="获取当前用户虚拟素材详情,asset_id 必须属于当前用户且 library_type=aigc_virtual。",
|
||||
)
|
||||
async def get_private_portrait_virtual_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == current_user.id, PrivatePortraitAsset.library_type == PrivatePortraitLibraryType.AIGC_VIRTUAL.value).limit(1))).scalar_one_or_none()
|
||||
if not asset:
|
||||
@@ -147,7 +241,12 @@ async def get_private_portrait_virtual_asset(asset_id: str, current_user: User =
|
||||
return asset_to_out(asset, project_name=project.name if project else None)
|
||||
|
||||
|
||||
@router.post("/private-portrait/virtual-assets/{asset_id}/sync", response_model=PrivatePortraitAssetOut, summary="同步虚拟人像素材状态")
|
||||
@router.post(
|
||||
"/private-portrait/virtual-assets/{asset_id}/sync",
|
||||
response_model=PrivatePortraitAssetOut,
|
||||
summary="同步虚拟素材状态",
|
||||
description="主动向火山查询并同步虚拟素材状态。一般由轮询任务自动执行;前端排查或手动刷新时可调用。",
|
||||
)
|
||||
async def sync_private_portrait_virtual_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
asset = await sync_asset_status(db, user_id=current_user.id, asset_id=asset_id)
|
||||
if asset.library_type != PrivatePortraitLibraryType.AIGC_VIRTUAL.value:
|
||||
@@ -157,7 +256,12 @@ async def sync_private_portrait_virtual_asset(asset_id: str, current_user: User
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/private-portrait/virtual-assets/{asset_id}", response_model=PrivatePortraitDeleteOut, summary="删除虚拟人像素材")
|
||||
@router.delete(
|
||||
"/private-portrait/virtual-assets/{asset_id}",
|
||||
response_model=PrivatePortraitDeleteOut,
|
||||
summary="删除虚拟素材",
|
||||
description="软删本地虚拟素材记录。本地先 commit,commit 成功后投递 Celery 删除远端 Asset;remote_delete_status=pending 表示远端删除处理中。",
|
||||
)
|
||||
async def delete_private_portrait_virtual_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
asset = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
asset_id_snapshot = asset.id
|
||||
@@ -173,7 +277,20 @@ async def delete_private_portrait_virtual_asset(asset_id: str, current_user: Use
|
||||
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||
|
||||
|
||||
@router.get("/private-portrait/virtual-selectable-assets", response_model=PrivatePortraitSelectableAssetListOut, summary="查询可用于生成的虚拟人像素材")
|
||||
async def list_private_portrait_virtual_selectable_assets(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), project_id: str | None = Query(None), keyword: str | None = Query(None), asset_type: str | None = Query(None, description="Image 或 Video,不传查全部。"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
@router.get(
|
||||
"/private-portrait/virtual-selectable-assets",
|
||||
response_model=PrivatePortraitSelectableAssetListOut,
|
||||
summary="查询可用于 AI 创作的虚拟素材",
|
||||
description="只返回当前用户虚拟素材库中 status=Active 且未删除的 Image/Video 素材。该接口给 AI 创作参考内容选择器使用。",
|
||||
)
|
||||
async def list_private_portrait_virtual_selectable_assets(
|
||||
page: int = Query(1, ge=1, description="页码,从 1 开始"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100"),
|
||||
project_id: str | None = Query(None, description="按虚拟项目 ID 筛选"),
|
||||
keyword: str | None = Query(None, description="素材名称模糊搜索"),
|
||||
asset_type: str | None = Query(None, description="素材类型:Image 或 Video,不传查全部"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
items, total = await list_selectable_assets(db, user_id=current_user.id, project_id=project_id, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, asset_type=asset_type)
|
||||
return PrivatePortraitSelectableAssetListOut(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, get_admin_user, get_current_user
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.recharge_package import RechargePackage
|
||||
from app.schemas.recharge_package import (
|
||||
RechargePackageCreate,
|
||||
RechargePackageUpdate,
|
||||
RechargePackageOut,
|
||||
)
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
router = APIRouter(tags=["recharge-packages"])
|
||||
|
||||
@@ -43,63 +37,3 @@ async def list_active_packages(
|
||||
.order_by(RechargePackage.sort_order)
|
||||
)
|
||||
return [_to_out(p) for p in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/admin/recharge-packages")
|
||||
async def admin_list_packages(
|
||||
_admin=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Admin: list all packages."""
|
||||
result = await db.execute(
|
||||
select(RechargePackage).order_by(RechargePackage.sort_order)
|
||||
)
|
||||
return [_to_out(p) for p in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/admin/recharge-packages")
|
||||
async def create_package(
|
||||
data: RechargePackageCreate,
|
||||
_admin=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
pkg = RechargePackage(id=generate_id(), **data.model_dump())
|
||||
db.add(pkg)
|
||||
await db.flush()
|
||||
return _to_out(pkg)
|
||||
|
||||
|
||||
@router.put("/admin/recharge-packages/{pkg_id}")
|
||||
async def update_package(
|
||||
pkg_id: str,
|
||||
data: RechargePackageUpdate,
|
||||
_admin=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(RechargePackage).where(RechargePackage.id == pkg_id).limit(1)
|
||||
)
|
||||
pkg = result.scalar_one_or_none()
|
||||
if not pkg:
|
||||
raise HTTPException(status_code=404, detail="套餐不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(pkg, k, v)
|
||||
await db.flush()
|
||||
return _to_out(pkg)
|
||||
|
||||
|
||||
@router.delete("/admin/recharge-packages/{pkg_id}")
|
||||
async def delete_package(
|
||||
pkg_id: str,
|
||||
_admin=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(RechargePackage).where(RechargePackage.id == pkg_id).limit(1)
|
||||
)
|
||||
pkg = result.scalar_one_or_none()
|
||||
if not pkg:
|
||||
raise HTTPException(status_code=404, detail="套餐不存在")
|
||||
await db.delete(pkg)
|
||||
await db.flush()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.models.user import User
|
||||
from app.enums.shot_replicate import (
|
||||
ModuleCodeEnum,
|
||||
ShotAnalysisStatusEnum,
|
||||
ShotReplicateLogEventEnum,
|
||||
ShotReplicateStepCodeEnum,
|
||||
ShotSegmentAnalysisStatusEnum,
|
||||
ShotSegmentReplicateStatusEnum,
|
||||
@@ -27,11 +28,14 @@ from app.schemas.shot_replicate import (
|
||||
ShotReplicateGenerateVideoPromptRequest,
|
||||
ShotReplicateGenerateVideoRequest,
|
||||
ShotReplicateImagePromptUpdateRequest,
|
||||
ShotReanalyzeOut,
|
||||
ShotReanalyzeRequest,
|
||||
ShotReplicateMaterialUpdateRequest,
|
||||
ShotReplicateSpecOut,
|
||||
ShotReplicateTaskDetailOut,
|
||||
ShotReplicateVideoPromptSchemaUpdateRequest,
|
||||
ShotSegmentDeleteOut,
|
||||
ShotTaskSetDeleteOut,
|
||||
ShotSegmentDetailOut,
|
||||
ShotSegmentListOut,
|
||||
ShotSegmentReplicationCreateRequest,
|
||||
@@ -46,7 +50,6 @@ from app.schemas.shot_replicate import (
|
||||
from app.services.shot_replicate_flow_service import (
|
||||
_get_project_for_user,
|
||||
create_shot_replicate_project_from_segment,
|
||||
delete_shot_replicate_project,
|
||||
generate_image_from_prompt,
|
||||
generate_video_from_prompt,
|
||||
mark_shot_replicate_step_dispatch_failed,
|
||||
@@ -62,9 +65,12 @@ from app.services.shot_replicate_taskset_service import (
|
||||
create_segments_by_ai,
|
||||
create_task_set,
|
||||
delete_segment,
|
||||
delete_task_set,
|
||||
get_segment_for_user,
|
||||
list_segments,
|
||||
list_task_sets,
|
||||
prepare_reanalyze_segment,
|
||||
prepare_reanalyze_task_set,
|
||||
segment_detail,
|
||||
task_set_detail,
|
||||
)
|
||||
@@ -78,6 +84,9 @@ from app.services.module_async_recovery_service import (
|
||||
register_shot_task_set_analysis_task,
|
||||
)
|
||||
from app.tasks.celery_app import celery_app
|
||||
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
|
||||
from app.services.upload_resource import upload_reference_file, bind_upload_resources, cleanup_upload_resource_files_after_commit
|
||||
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
|
||||
|
||||
MODULE = ModuleCodeEnum.SHOT_REPLICATE.value
|
||||
|
||||
@@ -152,7 +161,7 @@ def _log_api_exception_from_locals(exc: BaseException, local_values: dict, messa
|
||||
req = local_values.get("req")
|
||||
detail = {"api": local_values.get("__name__"), "request": req.model_dump() if hasattr(req, "model_dump") else str(req) if req is not None else None}
|
||||
_log_api_error(
|
||||
event_type="API_REQUEST_FAILED",
|
||||
event_type=ShotReplicateLogEventEnum.API_REQUEST_FAILED.value,
|
||||
current_user=current_user if isinstance(current_user, User) else None,
|
||||
project_id=str(project_id) if project_id else None,
|
||||
step_id=str(step_id) if step_id else None,
|
||||
@@ -168,7 +177,7 @@ def _ensure_celery_enabled(*, current_user: User | None = None, project_id: str
|
||||
return
|
||||
message = "Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker"
|
||||
_log_api_error(
|
||||
event_type="CELERY_DISABLED",
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISABLED.value,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
@@ -209,7 +218,7 @@ async def _mark_dispatch_failed_and_raise(
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_error(
|
||||
event_type="CELERY_DISPATCH_MARK_FAILED",
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
@@ -219,7 +228,7 @@ async def _mark_dispatch_failed_and_raise(
|
||||
)
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type="CELERY_DISPATCH_FAILED",
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
@@ -240,6 +249,66 @@ async def get_spec():
|
||||
return ShotReplicateSpecOut()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/upload-image",
|
||||
summary="上传拆镜复刻图片素材",
|
||||
description="上传后先记录为 pending UploadResource;创建拆镜复刻项目后绑定业务记录。",
|
||||
)
|
||||
async def upload_shot_replicate_image(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await upload_reference_file(
|
||||
db,
|
||||
file=file,
|
||||
current_user=current_user,
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
resource_type=UploadResourceTypeEnum.IMAGE.value,
|
||||
gen_type="video",
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"url": result.url,
|
||||
"filename": result.filename,
|
||||
"type": "image",
|
||||
"module": result.module,
|
||||
"resource_id": result.resource_id,
|
||||
"file_size_bytes": result.file_size_bytes,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/upload-video",
|
||||
summary="上传拆镜复刻视频素材",
|
||||
description="上传后先记录为 pending UploadResource;创建拆镜总任务集后绑定 ShotReplicateTaskSet。",
|
||||
)
|
||||
async def upload_shot_replicate_video(
|
||||
file: UploadFile = File(...),
|
||||
duration_seconds: float | None = Query(None, description="前端识别的视频时长秒数,可选"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await upload_reference_file(
|
||||
db,
|
||||
file=file,
|
||||
current_user=current_user,
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
resource_type=UploadResourceTypeEnum.VIDEO.value,
|
||||
duration_seconds=duration_seconds,
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"url": result.url,
|
||||
"filename": result.filename,
|
||||
"type": "video",
|
||||
"module": result.module,
|
||||
"resource_id": result.resource_id,
|
||||
"file_size_bytes": result.file_size_bytes,
|
||||
"duration_seconds": result.duration_seconds,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/task-sets",
|
||||
response_model=ShotTaskSetDetailOut,
|
||||
@@ -259,6 +328,16 @@ async def create_shot_task_set(
|
||||
try:
|
||||
task_set = await create_task_set(db, current_user=current_user, req=req)
|
||||
task_set_id = task_set.id
|
||||
await bind_upload_resources(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_TASK_SET.value,
|
||||
source_id=task_set_id,
|
||||
resource_ids=[req.video_resource_id],
|
||||
urls=[req.video_url],
|
||||
allow_common_migrate=True,
|
||||
)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
@@ -275,7 +354,7 @@ async def create_shot_task_set(
|
||||
analyze_original_video.apply_async(args=[task_set_id], queue="gen_chatapi_create", countdown=0)
|
||||
except Exception as exc:
|
||||
_log_api_error(
|
||||
event_type="CELERY_DISPATCH_FAILED",
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=task_set_id,
|
||||
message=f"拆镜分析任务投递失败: {exc}",
|
||||
@@ -345,6 +424,79 @@ async def get_shot_task_set(
|
||||
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/task-sets/{task_set_id}/reanalyze",
|
||||
response_model=ShotReanalyzeOut,
|
||||
summary="重新投递原视频 AI 分析任务",
|
||||
description="用于处理原视频分析失败或待处理的异常数据;重置分析状态后重新投递 analyze_original_video。",
|
||||
)
|
||||
async def reanalyze_task_set(
|
||||
task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"),
|
||||
req: ShotReanalyzeRequest = Body(default_factory=ShotReanalyzeRequest, description="再次分析参数"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ensure_celery_enabled(current_user=current_user, project_id=task_set_id)
|
||||
try:
|
||||
out = await prepare_reanalyze_task_set(
|
||||
db,
|
||||
current_user=current_user,
|
||||
task_set_id=task_set_id,
|
||||
force=req.force,
|
||||
reason=req.reason,
|
||||
)
|
||||
await db.commit()
|
||||
except HTTPException as exc:
|
||||
await db.rollback()
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_REJECTED.value,
|
||||
project_id=task_set_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
message="原视频再次分析请求被拒绝",
|
||||
detail={"task_set_id": task_set_id, "request": req.model_dump(), "http_status": exc.status_code, "detail": exc.detail},
|
||||
error=str(exc.detail),
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_error(
|
||||
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=task_set_id,
|
||||
message=f"原视频再次分析状态重置失败: {exc}",
|
||||
detail={"task_set_id": task_set_id, "request": req.model_dump()},
|
||||
exc=exc,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"原视频再次分析状态重置失败: {exc}")
|
||||
|
||||
try:
|
||||
from app.tasks.shot_replicate_tasks import analyze_original_video
|
||||
|
||||
await register_shot_task_set_analysis_task(task_set_id)
|
||||
analyze_original_video.apply_async(args=[task_set_id], queue="gen_chatapi_create", countdown=0)
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_SUBMITTED.value,
|
||||
project_id=task_set_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
message="原视频再次分析任务已投递",
|
||||
detail={"task_set_id": task_set_id, "task": "analyze_original_video", "request": req.model_dump()},
|
||||
)
|
||||
except Exception as exc:
|
||||
_log_api_error(
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=task_set_id,
|
||||
message=f"原视频再次分析任务投递失败: {exc}",
|
||||
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
|
||||
exc=exc,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=f"原视频再次分析任务投递失败: {exc}")
|
||||
out.message = "原视频再次分析任务已提交"
|
||||
return out
|
||||
|
||||
|
||||
@router.post(
|
||||
"/task-sets/{task_set_id}/split-by-ai",
|
||||
response_model=ShotSplitByAIOut,
|
||||
@@ -457,6 +609,82 @@ async def get_segment(
|
||||
return await segment_detail(db, current_user=current_user, segment_id=segment_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/segments/{segment_id}/reanalyze",
|
||||
response_model=ShotReanalyzeOut,
|
||||
summary="重新投递切片视频 AI 分析任务",
|
||||
description="用于处理自定义切片视频分析失败或待处理的异常数据;重置分析状态后重新投递 analyze_custom_segment_video。",
|
||||
)
|
||||
async def reanalyze_segment(
|
||||
segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"),
|
||||
req: ShotReanalyzeRequest = Body(default_factory=ShotReanalyzeRequest, description="再次分析参数"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ensure_celery_enabled(current_user=current_user, step_id=segment_id)
|
||||
try:
|
||||
out = await prepare_reanalyze_segment(
|
||||
db,
|
||||
current_user=current_user,
|
||||
segment_id=segment_id,
|
||||
force=req.force,
|
||||
reason=req.reason,
|
||||
)
|
||||
task_set_id = out.task_set_id
|
||||
await db.commit()
|
||||
except HTTPException as exc:
|
||||
await db.rollback()
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_REJECTED.value,
|
||||
step_id=segment_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
message="切片视频再次分析请求被拒绝",
|
||||
detail={"segment_id": segment_id, "request": req.model_dump(), "http_status": exc.status_code, "detail": exc.detail},
|
||||
error=str(exc.detail),
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_error(
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_FAILED.value,
|
||||
current_user=current_user,
|
||||
step_id=segment_id,
|
||||
message=f"切片视频再次分析状态重置失败: {exc}",
|
||||
detail={"segment_id": segment_id, "request": req.model_dump()},
|
||||
exc=exc,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"切片视频再次分析状态重置失败: {exc}")
|
||||
|
||||
try:
|
||||
from app.tasks.shot_replicate_tasks import analyze_custom_segment_video
|
||||
|
||||
await register_shot_segment_analysis_task(segment_id, task_set_id=task_set_id)
|
||||
analyze_custom_segment_video.apply_async(args=[segment_id], queue="gen_chatapi_create", countdown=0)
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_SUBMITTED.value,
|
||||
project_id=task_set_id,
|
||||
step_id=segment_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
message="切片视频再次分析任务已投递",
|
||||
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video", "request": req.model_dump()},
|
||||
)
|
||||
except Exception as exc:
|
||||
_log_api_error(
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=task_set_id,
|
||||
step_id=segment_id,
|
||||
message=f"切片视频再次分析任务投递失败: {exc}",
|
||||
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video"},
|
||||
exc=exc,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=f"切片视频再次分析任务投递失败: {exc}")
|
||||
out.message = "切片视频再次分析任务已提交"
|
||||
return out
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/segments/{segment_id}",
|
||||
response_model=ShotSegmentDeleteOut,
|
||||
@@ -472,18 +700,69 @@ async def delete_shot_segment(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user_id = _safe_user_id(current_user)
|
||||
pending_ids: list[str] = []
|
||||
try:
|
||||
out = await delete_segment(db, current_user=current_user, segment_id=segment_id)
|
||||
pending_ids = list(out.pending_delete_resource_ids or [])
|
||||
await db.commit()
|
||||
return out
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
except HTTPException as exc:
|
||||
await safe_rollback_with_log(
|
||||
db,
|
||||
event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_ROLLBACK_FAILED.value,
|
||||
message="删除拆镜片段 HTTPException 回滚失败",
|
||||
user_id=user_id,
|
||||
module=MODULE,
|
||||
detail={"segment_id": segment_id},
|
||||
original_exc=exc,
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await safe_rollback_with_log(
|
||||
db,
|
||||
event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_ROLLBACK_FAILED.value,
|
||||
message="删除拆镜片段主事务回滚失败",
|
||||
user_id=user_id,
|
||||
module=MODULE,
|
||||
detail={"segment_id": segment_id},
|
||||
original_exc=exc,
|
||||
)
|
||||
_log_api_exception_from_locals(exc, locals(), f"删除拆镜片段失败: {exc}")
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_FAILED.value,
|
||||
message=f"删除拆镜片段失败: {exc}",
|
||||
user_id=user_id,
|
||||
module=MODULE,
|
||||
detail={"segment_id": segment_id},
|
||||
exc=exc,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"删除拆镜片段失败: {exc}")
|
||||
|
||||
if pending_ids:
|
||||
try:
|
||||
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids)
|
||||
await db.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await safe_rollback_with_log(
|
||||
db,
|
||||
event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_ROLLBACK_FAILED.value,
|
||||
message="删除拆镜片段 cleanup 事务回滚失败",
|
||||
user_id=user_id,
|
||||
module=MODULE,
|
||||
detail={"segment_id": segment_id, "pending_ids": pending_ids},
|
||||
original_exc=exc,
|
||||
)
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_CLEANUP_FAILED.value,
|
||||
message=f"删除拆镜片段后清理真实文件失败: {exc}",
|
||||
user_id=user_id,
|
||||
resource_ids=pending_ids,
|
||||
module=MODULE,
|
||||
detail={"segment_id": segment_id},
|
||||
exc=exc,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.post(
|
||||
"/segments/{segment_id}/replication-projects",
|
||||
@@ -796,24 +1075,75 @@ async def generate_video(
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/projects/{project_id}",
|
||||
response_model=ShotReplicateDeleteOut,
|
||||
summary="软删除拆镜复刻项目",
|
||||
description="软删除拆镜复刻 ModuleGenerationProject,并联动软删除当前有效步骤和关联的 ChatGenerationTask。",
|
||||
"/task-sets/{task_set_id}",
|
||||
response_model=ShotTaskSetDeleteOut,
|
||||
summary="软删除拆镜任务集",
|
||||
description="软删除整个 ShotReplicateTaskSet,并联动软删除全部片段和片段内部复刻项目;UploadResource 真实文件在事务提交后清理。",
|
||||
)
|
||||
async def delete_project(
|
||||
project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
|
||||
async def delete_shot_task_set(
|
||||
task_set_id: str = Path(..., description="拆镜任务集ID,即 shot_replicate_task_sets.id"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user_id = _safe_user_id(current_user)
|
||||
pending_ids: list[str] = []
|
||||
try:
|
||||
out = await delete_shot_replicate_project(db, current_user=current_user, project_id=project_id)
|
||||
out = await delete_task_set(db, current_user=current_user, task_set_id=task_set_id)
|
||||
pending_ids = list(out.pending_delete_resource_ids or [])
|
||||
await db.commit()
|
||||
return out
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
except HTTPException as exc:
|
||||
await safe_rollback_with_log(
|
||||
db,
|
||||
event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_ROLLBACK_FAILED.value,
|
||||
message="删除拆镜任务集 HTTPException 回滚失败",
|
||||
user_id=user_id,
|
||||
module=MODULE,
|
||||
detail={"task_set_id": task_set_id},
|
||||
original_exc=exc,
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"删除拆镜复刻项目失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"删除拆镜复刻项目失败: {exc}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await safe_rollback_with_log(
|
||||
db,
|
||||
event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_ROLLBACK_FAILED.value,
|
||||
message="删除拆镜任务集主事务回滚失败",
|
||||
user_id=user_id,
|
||||
module=MODULE,
|
||||
detail={"task_set_id": task_set_id},
|
||||
original_exc=exc,
|
||||
)
|
||||
_log_api_exception_from_locals(exc, locals(), f"删除拆镜任务集失败: {exc}")
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_FAILED.value,
|
||||
message=f"删除拆镜任务集失败: {exc}",
|
||||
user_id=user_id,
|
||||
module=MODULE,
|
||||
detail={"task_set_id": task_set_id},
|
||||
exc=exc,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"删除拆镜任务集失败: {exc}")
|
||||
|
||||
if pending_ids:
|
||||
try:
|
||||
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids)
|
||||
await db.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await safe_rollback_with_log(
|
||||
db,
|
||||
event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_ROLLBACK_FAILED.value,
|
||||
message="删除拆镜任务集 cleanup 事务回滚失败",
|
||||
user_id=user_id,
|
||||
module=MODULE,
|
||||
detail={"task_set_id": task_set_id, "pending_ids": pending_ids},
|
||||
original_exc=exc,
|
||||
)
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_CLEANUP_FAILED.value,
|
||||
message=f"删除拆镜任务集后清理真实文件失败: {exc}",
|
||||
user_id=user_id,
|
||||
resource_ids=pending_ids,
|
||||
module=MODULE,
|
||||
detail={"task_set_id": task_set_id},
|
||||
exc=exc,
|
||||
)
|
||||
return out
|
||||
|
||||
@@ -245,13 +245,14 @@ async def get_join_info_public(
|
||||
|
||||
@router.get("/join-requests", )
|
||||
async def list_join_requests(
|
||||
status: str | None = Query(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
team = await get_managed_team(db, current_user.id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
|
||||
requests = await team_invitation_service.get_pending_requests(db, team.id)
|
||||
requests = await team_invitation_service.get_all_requests(db, team.id, status)
|
||||
|
||||
# 获取团队名
|
||||
team_name_result = await db.execute(
|
||||
@@ -269,7 +270,8 @@ async def list_join_requests(
|
||||
phone=r.get("phone"),
|
||||
status=r["status"],
|
||||
note=r.get("note"),
|
||||
created_at=r.get("created_at"),
|
||||
created_at=r["created_at"],
|
||||
handled_at=r.get("handled_at"),
|
||||
)
|
||||
for r in requests
|
||||
]
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.enums.upload_resource import UploadResourceEventEnum
|
||||
from app.models.user import User
|
||||
from app.schemas.upload_resource import (
|
||||
UploadResourceHistoryBatchDeleteOut,
|
||||
UploadResourceHistoryBatchDeleteRequest,
|
||||
UploadResourceHistoryDayItemsOut,
|
||||
UploadResourceHistoryGroupedOut,
|
||||
)
|
||||
from app.services.upload_resource.delete_service import (
|
||||
cleanup_upload_resource_history_files,
|
||||
mark_upload_resource_history_deleted,
|
||||
)
|
||||
from app.services.upload_resource.history_service import (
|
||||
list_upload_resource_history_day_items,
|
||||
list_upload_resource_history_grouped_days,
|
||||
)
|
||||
from app.services.upload_resource.log_service import (
|
||||
log_upload_resource_exception,
|
||||
log_upload_resource_event,
|
||||
safe_rollback_with_log,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/upload-resources",
|
||||
tags=["upload-resources"],
|
||||
)
|
||||
|
||||
_HISTORY_DESCRIPTION = (
|
||||
"查询当前用户可展示、可复用、可删除的上传素材历史。"
|
||||
"只返回 UploadResource 中未删除、未绑定业务记录、bind_status=pending、"
|
||||
"delete_policy=user_deletable、resource_type=image/video/audio 的记录。"
|
||||
"已绑定爆款开头复刻、拆镜复刻、拆镜切片或其它业务记录的素材不会返回,也不能通过该模块删除。"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/history",
|
||||
response_model=UploadResourceHistoryGroupedOut,
|
||||
summary="获取上传历史素材日期分组",
|
||||
description=(
|
||||
_HISTORY_DESCRIPTION
|
||||
+ "返回结构与 /generation-ai/history 接近,generated_date 表示上传日期。"
|
||||
"每个日期默认回填前 10 条 items,避免前端首屏再次逐日请求。"
|
||||
),
|
||||
responses={
|
||||
200: {"description": "查询成功,返回上传素材日期分组"},
|
||||
400: {"description": "resource_type 不合法或分页参数不合法"},
|
||||
401: {"description": "未登录或 Token 无效"},
|
||||
},
|
||||
)
|
||||
async def list_history_grouped_days(
|
||||
resource_type: str | None = Query(
|
||||
None,
|
||||
description="素材类型筛选:image=图片,video=视频,audio=音频;为空表示全部",
|
||||
examples=["image"],
|
||||
),
|
||||
page: int = Query(1, ge=1, description="日期分组页码,从 1 开始"),
|
||||
page_size: int = Query(10, ge=1, le=10, description="日期分组每页数量,最大 10"),
|
||||
keyword: str | None = Query(None, description="文件名或资源 URL 模糊搜索,可为空"),
|
||||
scene: str | None = Query(
|
||||
None,
|
||||
description="前端使用场景标识:record=素材云展示,picker=AI创作选择器;仅用于日志排查,不影响过滤规则",
|
||||
examples=["picker"],
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
return await list_upload_resource_history_grouped_days(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
resource_type=resource_type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
keyword=keyword,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.HISTORY_LIST_FAILED.value,
|
||||
message=f"查询上传历史素材日期分组失败: {exc}",
|
||||
user_id=current_user.id,
|
||||
detail={"resource_type": resource_type, "page": page, "page_size": page_size, "keyword": keyword, "scene": scene},
|
||||
exc=exc,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@router.get(
|
||||
"/history/{generated_date}",
|
||||
response_model=UploadResourceHistoryDayItemsOut,
|
||||
summary="获取指定日期下上传历史素材",
|
||||
description=(
|
||||
_HISTORY_DESCRIPTION
|
||||
+ "generated_date 格式为 YYYY-MM-DD,字段名沿用生成历史接口,实际语义为上传日期。"
|
||||
),
|
||||
responses={
|
||||
200: {"description": "查询成功,返回指定上传日期下的素材列表"},
|
||||
400: {"description": "日期格式错误、resource_type 不合法或分页参数不合法"},
|
||||
401: {"description": "未登录或 Token 无效"},
|
||||
},
|
||||
)
|
||||
async def list_history_day_items(
|
||||
generated_date: str = Path(..., description="上传日期,格式 YYYY-MM-DD", examples=["2026-07-08"]),
|
||||
resource_type: str | None = Query(
|
||||
None,
|
||||
description="素材类型筛选:image=图片,video=视频,audio=音频;为空表示全部",
|
||||
examples=["video"],
|
||||
),
|
||||
page: int = Query(1, ge=1, description="当前日期下分页页码,从 1 开始"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="当前日期下每页数量,最大 100"),
|
||||
keyword: str | None = Query(None, description="文件名或资源 URL 模糊搜索,可为空"),
|
||||
scene: str | None = Query(
|
||||
None,
|
||||
description="前端使用场景标识:record=素材云展示,picker=AI创作选择器;仅用于日志排查,不影响过滤规则",
|
||||
examples=["record"],
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
return await list_upload_resource_history_day_items(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
generated_date=generated_date,
|
||||
resource_type=resource_type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
keyword=keyword,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.HISTORY_DAY_LIST_FAILED.value,
|
||||
message=f"查询指定日期上传历史素材失败: {exc}",
|
||||
user_id=current_user.id,
|
||||
detail={"generated_date": generated_date, "resource_type": resource_type, "page": page, "page_size": page_size, "keyword": keyword, "scene": scene},
|
||||
exc=exc,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/history/batch",
|
||||
response_model=UploadResourceHistoryBatchDeleteOut,
|
||||
summary="批量删除上传历史素材",
|
||||
description=(
|
||||
"批量删除当前用户的上传历史素材。一次最多 30 条。"
|
||||
"只允许删除未绑定业务记录、bind_status=pending、delete_policy=user_deletable 的 UploadResource。"
|
||||
"删除顺序为:主事务内软删 DB 记录并释放上传容量 -> commit 成功 -> 再删除真实静态文件 -> 写回真实文件删除状态。"
|
||||
"如果真实文件删除失败,主删除不回滚,file_delete_status 会保留 delete_failed/pending_delete 供后续补偿排查。"
|
||||
),
|
||||
responses={
|
||||
200: {"description": "删除成功,返回软删数量、释放容量和真实文件清理结果"},
|
||||
400: {"description": "resource_ids 为空、重复或超过 30 条"},
|
||||
404: {"description": "部分素材不存在、已删除、已绑定业务记录或无权操作"},
|
||||
401: {"description": "未登录或 Token 无效"},
|
||||
},
|
||||
)
|
||||
async def batch_delete_upload_history(
|
||||
req: UploadResourceHistoryBatchDeleteRequest = Body(..., description="批量删除上传历史素材请求体"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result: UploadResourceHistoryBatchDeleteOut | None = None
|
||||
user_id = str(current_user.id)
|
||||
try:
|
||||
result = await mark_upload_resource_history_deleted(db, current_user=current_user, resource_ids=req.resource_ids)
|
||||
await db.commit()
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.DELETE_BATCH_COMMIT_SUCCESS.value,
|
||||
user_id=user_id,
|
||||
detail={
|
||||
"requested_ids": result.requested_ids,
|
||||
"deleted_ids": result.deleted_ids,
|
||||
"released_size_bytes": result.released_size_bytes,
|
||||
},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await safe_rollback_with_log(
|
||||
db,
|
||||
event_type=UploadResourceEventEnum.DELETE_BATCH_ROLLBACK_FAILED.value,
|
||||
message="批量删除上传历史素材主事务回滚失败",
|
||||
user_id=user_id,
|
||||
detail={"resource_ids": req.resource_ids},
|
||||
original_exc=exc,
|
||||
)
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.DELETE_BATCH_FAILED.value,
|
||||
message=f"批量删除上传历史素材失败: {exc}",
|
||||
user_id=user_id,
|
||||
resource_ids=req.resource_ids,
|
||||
detail={"resource_ids": req.resource_ids},
|
||||
exc=exc,
|
||||
)
|
||||
raise
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=500, detail="批量删除上传历史素材失败")
|
||||
|
||||
try:
|
||||
result = await cleanup_upload_resource_history_files(db, result=result, current_user=current_user)
|
||||
await db.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await safe_rollback_with_log(
|
||||
db,
|
||||
event_type=UploadResourceEventEnum.DELETE_BATCH_ROLLBACK_FAILED.value,
|
||||
message="批量删除上传历史素材 cleanup 事务回滚失败",
|
||||
user_id=user_id,
|
||||
detail={"resource_ids": req.resource_ids, "deleted_ids": result.deleted_ids if result else []},
|
||||
original_exc=exc,
|
||||
)
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.DELETE_BATCH_CLEANUP_FAILED.value,
|
||||
message=f"批量删除上传历史素材后清理真实文件失败: {exc}",
|
||||
user_id=user_id,
|
||||
resource_ids=result.deleted_ids if result else req.resource_ids,
|
||||
detail={"resource_ids": req.resource_ids, "deleted_ids": result.deleted_ids if result else []},
|
||||
exc=exc,
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
"""项目 CLI 脚本包。"""
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
|
||||
from app.models.base import async_session
|
||||
from app.enums.upload_resource import UploadResourceEventEnum
|
||||
from app.services.upload_resource.backfill_service import BackfillOptions, run_upload_resource_backfill
|
||||
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
|
||||
|
||||
|
||||
VALID_MODULES = {"common", "hot_opening_replicate", "shot_replicate"}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="补录 storage/uploads 下的上传资源账本,并可重绑模块归属。")
|
||||
parser.add_argument("--root", default="storage/uploads", help="扫描根目录,默认 storage/uploads")
|
||||
parser.add_argument("--batch-size", type=int, default=500, help="每批处理数量,默认 500")
|
||||
parser.add_argument("--dry-run", action="store_true", help="试跑,不写数据库")
|
||||
parser.add_argument("--commit", action="store_true", help="真实执行写入")
|
||||
parser.add_argument("--include-legacy", action="store_true", help="补录 gen_{user_id}_* 等旧版散落文件")
|
||||
parser.add_argument("--rebind-modules", action="store_true", help="根据 hot/shot 业务表中保存的 URL 重新绑定模块归属")
|
||||
parser.add_argument("--rebuild-stats", action="store_true", help="补录完成后按资源账本重算容量统计")
|
||||
parser.add_argument("--only-user-id", default=None, help="只处理指定用户ID")
|
||||
parser.add_argument("--only-module", choices=sorted(VALID_MODULES), default=None, help="只处理指定模块")
|
||||
parser.add_argument("--cleanup-pending-files", action="store_true", help="清理已软删但真实文件待删除/删除失败的 UploadResource 文件")
|
||||
parser.add_argument("--cleanup-limit", type=int, default=500, help="每次最多清理待删除真实文件数量,默认 500")
|
||||
return parser
|
||||
|
||||
|
||||
async def amain(argv: list[str]) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if args.dry_run and args.commit:
|
||||
parser.error("--dry-run 和 --commit 不能同时使用")
|
||||
if not args.dry_run and not args.commit:
|
||||
args.dry_run = True
|
||||
if args.batch_size <= 0:
|
||||
parser.error("--batch-size 必须大于 0")
|
||||
|
||||
options = BackfillOptions(
|
||||
root=args.root,
|
||||
batch_size=args.batch_size,
|
||||
dry_run=not args.commit,
|
||||
include_legacy=args.include_legacy,
|
||||
rebind_modules=args.rebind_modules,
|
||||
rebuild_stats=args.rebuild_stats,
|
||||
only_user_id=args.only_user_id,
|
||||
only_module=args.only_module,
|
||||
cleanup_pending_files=args.cleanup_pending_files,
|
||||
cleanup_limit=args.cleanup_limit,
|
||||
)
|
||||
|
||||
async with async_session() as db:
|
||||
try:
|
||||
result = await run_upload_resource_backfill(db, options)
|
||||
if options.dry_run:
|
||||
await db.rollback()
|
||||
else:
|
||||
await db.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await safe_rollback_with_log(
|
||||
db,
|
||||
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_BACKFILL_CLI_ROLLBACK_FAILED.value,
|
||||
message="UploadResource 补录/清理 CLI 回滚失败",
|
||||
detail={"argv": argv, "options": vars(args)},
|
||||
original_exc=exc,
|
||||
)
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_BACKFILL_CLI_FAILED.value,
|
||||
message=f"UploadResource 补录/清理 CLI 执行失败: {exc}",
|
||||
detail={"argv": argv, "options": vars(args)},
|
||||
exc=exc,
|
||||
)
|
||||
raise
|
||||
|
||||
print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return asyncio.run(amain(sys.argv[1:]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -228,7 +228,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# 拆镜复刻配置。
|
||||
# 原始上传视频和拆镜片段都属于 uploads 素材域;只有 generate 生成结果走 token 验签。
|
||||
SHOT_ANALYSIS_TIMEOUT_SECONDS: int = 180
|
||||
SHOT_ANALYSIS_TIMEOUT_SECONDS: int = 600
|
||||
SHOT_ANALYSIS_TEMPERATURE: float = 0.1
|
||||
SHOT_ANALYSIS_MAX_TOKENS: int = 5000
|
||||
SHOT_ANALYSIS_VIDEO_FPS: float = 1.0
|
||||
|
||||
@@ -3,6 +3,30 @@ from __future__ import annotations
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
|
||||
|
||||
class LogEventStatusEnum(StrEnum):
|
||||
"""通用日志事件状态。"""
|
||||
|
||||
PENDING = "pending"
|
||||
STARTED = "started"
|
||||
SUBMITTED = "submitted"
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
REJECTED = "rejected"
|
||||
SKIPPED = "skipped"
|
||||
WARNING = "warning"
|
||||
|
||||
|
||||
class LogSourceEnum(StrEnum):
|
||||
"""通用日志来源。"""
|
||||
|
||||
API = "api"
|
||||
SERVICE = "service"
|
||||
CELERY = "celery"
|
||||
RECOVERY = "recovery"
|
||||
REMOTE_API = "remote_api"
|
||||
|
||||
class ModuleProjectStatusEnum(StrEnum):
|
||||
"""通用模块项目状态。"""
|
||||
|
||||
|
||||
@@ -30,3 +30,32 @@ class HotOpeningStepIOSchemaVersionEnum(StrEnum):
|
||||
"""爆款开头复刻子任务 input_json/output_json 结构版本。"""
|
||||
|
||||
V1 = "hot_opening_step_io_v1"
|
||||
|
||||
|
||||
class HotOpeningLogEventEnum(StrEnum):
|
||||
"""爆款开头复刻模块业务日志事件。"""
|
||||
|
||||
API_REQUEST_RECEIVED = "HOT_OPENING_API_REQUEST_RECEIVED"
|
||||
API_REQUEST_SUBMITTED = "HOT_OPENING_API_REQUEST_SUBMITTED"
|
||||
API_REQUEST_REJECTED = "HOT_OPENING_API_REQUEST_REJECTED"
|
||||
API_REQUEST_FAILED = "HOT_OPENING_API_REQUEST_FAILED"
|
||||
CELERY_DISABLED = "HOT_OPENING_CELERY_DISABLED"
|
||||
CELERY_DISPATCH_FAILED = "HOT_OPENING_CELERY_DISPATCH_FAILED"
|
||||
CELERY_DISPATCH_MARK_FAILED = "HOT_OPENING_CELERY_DISPATCH_MARK_FAILED"
|
||||
|
||||
IMAGE_PROMPT_SUBMITTED = "HOT_OPENING_IMAGE_PROMPT_SUBMITTED"
|
||||
IMAGE_GENERATE_SUBMITTED = "HOT_OPENING_IMAGE_GENERATE_SUBMITTED"
|
||||
VIDEO_PROMPT_SUBMITTED = "HOT_OPENING_VIDEO_PROMPT_SUBMITTED"
|
||||
VIDEO_GENERATE_SUBMITTED = "HOT_OPENING_VIDEO_GENERATE_SUBMITTED"
|
||||
|
||||
VIDEO_PROMPT_REMOTE_API_STARTED = "HOT_OPENING_VIDEO_PROMPT_REMOTE_API_STARTED"
|
||||
VIDEO_PROMPT_REMOTE_API_SUCCESS = "HOT_OPENING_VIDEO_PROMPT_REMOTE_API_SUCCESS"
|
||||
VIDEO_PROMPT_REMOTE_API_FAILED = "HOT_OPENING_VIDEO_PROMPT_REMOTE_API_FAILED"
|
||||
VIDEO_PROMPT_RESPONSE_PARSE_FAILED = "HOT_OPENING_VIDEO_PROMPT_RESPONSE_PARSE_FAILED"
|
||||
VIDEO_PROMPT_RESPONSE_EMPTY = "HOT_OPENING_VIDEO_PROMPT_RESPONSE_EMPTY"
|
||||
|
||||
|
||||
class HotOpeningRemoteActionEnum(StrEnum):
|
||||
"""爆款开头复刻远程模型动作。"""
|
||||
|
||||
VIDEO_PROMPT_OPTIMIZE = "video_prompt_optimize"
|
||||
|
||||
@@ -90,3 +90,54 @@ class ShotSegmentReplicateStatusEnum(StrEnum):
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class ShotReplicateLogEventEnum(StrEnum):
|
||||
"""拆镜复刻模块业务日志事件。"""
|
||||
|
||||
API_REQUEST_RECEIVED = "SHOT_API_REQUEST_RECEIVED"
|
||||
API_REQUEST_SUBMITTED = "SHOT_API_REQUEST_SUBMITTED"
|
||||
API_REQUEST_REJECTED = "SHOT_API_REQUEST_REJECTED"
|
||||
API_REQUEST_FAILED = "SHOT_API_REQUEST_FAILED"
|
||||
CELERY_DISABLED = "SHOT_CELERY_DISABLED"
|
||||
CELERY_DISPATCH_FAILED = "SHOT_CELERY_DISPATCH_FAILED"
|
||||
CELERY_DISPATCH_MARK_FAILED = "SHOT_CELERY_DISPATCH_MARK_FAILED"
|
||||
|
||||
TASK_SET_CREATED = "SHOT_TASK_SET_CREATED"
|
||||
TASK_SET_REANALYZE_RECEIVED = "SHOT_TASK_SET_REANALYZE_RECEIVED"
|
||||
TASK_SET_REANALYZE_SUBMITTED = "SHOT_TASK_SET_REANALYZE_SUBMITTED"
|
||||
TASK_SET_REANALYZE_REJECTED = "SHOT_TASK_SET_REANALYZE_REJECTED"
|
||||
TASK_SET_REANALYZE_FAILED = "SHOT_TASK_SET_REANALYZE_FAILED"
|
||||
|
||||
ANALYSIS_STARTED = "SHOT_ANALYSIS_STARTED"
|
||||
ANALYSIS_SUCCESS = "SHOT_ANALYSIS_SUCCESS"
|
||||
ANALYSIS_FAILED = "SHOT_ANALYSIS_FAILED"
|
||||
ANALYSIS_REMOTE_API_STARTED = "SHOT_ANALYSIS_REMOTE_API_STARTED"
|
||||
ANALYSIS_REMOTE_API_SUCCESS = "SHOT_ANALYSIS_REMOTE_API_SUCCESS"
|
||||
ANALYSIS_REMOTE_API_FAILED = "SHOT_ANALYSIS_REMOTE_API_FAILED"
|
||||
ANALYSIS_RESPONSE_PARSE_FAILED = "SHOT_ANALYSIS_RESPONSE_PARSE_FAILED"
|
||||
ANALYSIS_RESPONSE_EMPTY = "SHOT_ANALYSIS_RESPONSE_EMPTY"
|
||||
|
||||
SEGMENT_REANALYZE_RECEIVED = "SHOT_SEGMENT_REANALYZE_RECEIVED"
|
||||
SEGMENT_REANALYZE_SUBMITTED = "SHOT_SEGMENT_REANALYZE_SUBMITTED"
|
||||
SEGMENT_REANALYZE_REJECTED = "SHOT_SEGMENT_REANALYZE_REJECTED"
|
||||
SEGMENT_REANALYZE_FAILED = "SHOT_SEGMENT_REANALYZE_FAILED"
|
||||
SEGMENT_ANALYSIS_STARTED = "SHOT_SEGMENT_ANALYSIS_STARTED"
|
||||
SEGMENT_ANALYSIS_SUCCESS = "SHOT_SEGMENT_ANALYSIS_SUCCESS"
|
||||
SEGMENT_ANALYSIS_FAILED = "SHOT_SEGMENT_ANALYSIS_FAILED"
|
||||
SEGMENT_ANALYSIS_REMOTE_API_STARTED = "SHOT_SEGMENT_ANALYSIS_REMOTE_API_STARTED"
|
||||
SEGMENT_ANALYSIS_REMOTE_API_SUCCESS = "SHOT_SEGMENT_ANALYSIS_REMOTE_API_SUCCESS"
|
||||
SEGMENT_ANALYSIS_REMOTE_API_FAILED = "SHOT_SEGMENT_ANALYSIS_REMOTE_API_FAILED"
|
||||
|
||||
SPLIT_STATUS_CHANGED = "SHOT_SPLIT_STATUS_CHANGED"
|
||||
SPLIT_BY_AI_SUBMITTED = "SHOT_SPLIT_BY_AI_SUBMITTED"
|
||||
SPLIT_CUSTOM_SUBMITTED = "SHOT_SPLIT_CUSTOM_SUBMITTED"
|
||||
SEGMENT_DELETED = "SHOT_SEGMENT_DELETED"
|
||||
|
||||
|
||||
class ShotReplicateRemoteActionEnum(StrEnum):
|
||||
"""拆镜复刻远程模型动作。"""
|
||||
|
||||
ANALYZE_ORIGINAL_VIDEO = "analyze_original_video"
|
||||
ANALYZE_CUSTOM_SEGMENT_VIDEO = "analyze_custom_segment_video"
|
||||
VIDEO_PROMPT_OPTIMIZE = "video_prompt_optimize"
|
||||
|
||||
@@ -11,5 +11,19 @@ TEAM_STATUS_LABELS = {
|
||||
TeamStatus.DISABLED.value: "禁用",
|
||||
}
|
||||
|
||||
|
||||
class TeamJoinRequestStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
TEAM_JOIN_REQUEST_STATUS_LABELS = {
|
||||
TeamJoinRequestStatus.PENDING.value: "待处理",
|
||||
TeamJoinRequestStatus.APPROVED.value: "已通过",
|
||||
TeamJoinRequestStatus.REJECTED.value: "已拒绝",
|
||||
}
|
||||
|
||||
|
||||
# 前端筛选“未分配团队”时使用的稳定哨兵值,不与真实团队ID混用。
|
||||
TEAM_UNASSIGNED_VALUE = "__none__"
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class UploadResourceModuleEnum(StrEnum):
|
||||
"""上传资源所属业务模块。"""
|
||||
|
||||
COMMON = "common"
|
||||
HOT_OPENING_REPLICATE = "hot_opening_replicate"
|
||||
SHOT_REPLICATE = "shot_replicate"
|
||||
|
||||
|
||||
class UploadResourceTypeEnum(StrEnum):
|
||||
"""上传资源类型。"""
|
||||
|
||||
IMAGE = "image"
|
||||
VIDEO = "video"
|
||||
AUDIO = "audio"
|
||||
SHOT_SEGMENT = "shot_segment"
|
||||
|
||||
|
||||
class UploadResourceBindStatusEnum(StrEnum):
|
||||
"""上传资源绑定状态。"""
|
||||
|
||||
PENDING = "pending"
|
||||
BOUND = "bound"
|
||||
|
||||
|
||||
class UploadResourceDeletePolicyEnum(StrEnum):
|
||||
"""上传资源删除策略。"""
|
||||
|
||||
USER_DELETABLE = "user_deletable"
|
||||
MODULE_ONLY = "module_only"
|
||||
SYSTEM_ONLY = "system_only"
|
||||
|
||||
|
||||
class UploadResourceCreatedByEnum(StrEnum):
|
||||
"""上传资源创建来源。"""
|
||||
|
||||
API = "api"
|
||||
BACKFILL = "backfill"
|
||||
SPLIT_TASK = "split_task"
|
||||
SYSTEM_CLEANUP = "system_cleanup"
|
||||
|
||||
|
||||
class UploadResourceDurationSourceEnum(StrEnum):
|
||||
"""音视频秒数来源。"""
|
||||
|
||||
FFPROBE = "ffprobe"
|
||||
CLIENT = "client"
|
||||
BUSINESS = "business"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class UploadResourceFileDeleteStatusEnum(StrEnum):
|
||||
"""上传资源真实文件删除状态。"""
|
||||
|
||||
ACTIVE = "active"
|
||||
PENDING_DELETE = "pending_delete"
|
||||
DELETED = "deleted"
|
||||
DELETE_FAILED = "delete_failed"
|
||||
|
||||
DELETE_MARKED_PENDING = "delete_marked_pending"
|
||||
DELETE_PHYSICAL_SUCCESS = "delete_physical_success"
|
||||
DELETE_PHYSICAL_MISSING = "delete_physical_missing"
|
||||
DELETE_PHYSICAL_FAILED = "delete_physical_failed"
|
||||
CLEANUP_PENDING_START = "cleanup_pending_start"
|
||||
CLEANUP_PENDING_FINISHED = "cleanup_pending_finished"
|
||||
MISSING = "missing"
|
||||
|
||||
|
||||
class UploadResourceSourceModelEnum(StrEnum):
|
||||
"""上传资源业务来源模型名。"""
|
||||
|
||||
MODULE_GENERATION_PROJECT = "ModuleGenerationProject"
|
||||
SHOT_REPLICATE_TASK_SET = "ShotReplicateTaskSet"
|
||||
SHOT_REPLICATE_SEGMENT = "ShotReplicateSegment"
|
||||
|
||||
|
||||
class UploadResourceEventEnum(StrEnum):
|
||||
"""上传资源日志事件。"""
|
||||
|
||||
UPLOAD_START = "upload_start"
|
||||
UPLOAD_CAPACITY_CHECKED = "upload_capacity_checked"
|
||||
UPLOAD_CAPACITY_REJECTED = "upload_capacity_rejected"
|
||||
UPLOAD_FILE_SAVED = "upload_file_saved"
|
||||
UPLOAD_DB_RECORDED = "upload_db_recorded"
|
||||
UPLOAD_FAILED = "upload_failed"
|
||||
|
||||
DELETE_START = "delete_start"
|
||||
DELETE_REJECTED_BOUND = "delete_rejected_bound"
|
||||
DELETE_FILE_REMOVED = "delete_file_removed"
|
||||
DELETE_CAPACITY_RELEASED = "delete_capacity_released"
|
||||
DELETE_FAILED = "delete_failed"
|
||||
|
||||
DELETE_MARKED_PENDING = "delete_marked_pending"
|
||||
DELETE_PHYSICAL_SUCCESS = "delete_physical_success"
|
||||
DELETE_PHYSICAL_MISSING = "delete_physical_missing"
|
||||
DELETE_PHYSICAL_FAILED = "delete_physical_failed"
|
||||
CLEANUP_PENDING_START = "cleanup_pending_start"
|
||||
CLEANUP_PENDING_FINISHED = "cleanup_pending_finished"
|
||||
|
||||
BIND_START = "bind_start"
|
||||
BIND_SUCCESS = "bind_success"
|
||||
BIND_CONFLICT = "bind_conflict"
|
||||
BIND_SKIPPED = "bind_skipped"
|
||||
|
||||
BACKFILL_START = "backfill_start"
|
||||
BACKFILL_FILE_MATCHED = "backfill_file_matched"
|
||||
BACKFILL_FILE_INSERTED = "backfill_file_inserted"
|
||||
BACKFILL_FILE_EXISTS = "backfill_file_exists"
|
||||
BACKFILL_FILE_SKIPPED = "backfill_file_skipped"
|
||||
BACKFILL_REBIND_DONE = "backfill_rebind_done"
|
||||
BACKFILL_STATS_REBUILT = "backfill_stats_rebuilt"
|
||||
BACKFILL_FINISHED = "backfill_finished"
|
||||
|
||||
|
||||
HISTORY_LIST_START = "history_list_start"
|
||||
HISTORY_LIST_SUCCESS = "history_list_success"
|
||||
HISTORY_LIST_FAILED = "history_list_failed"
|
||||
HISTORY_DAY_LIST_START = "history_day_list_start"
|
||||
HISTORY_DAY_LIST_SUCCESS = "history_day_list_success"
|
||||
HISTORY_DAY_LIST_FAILED = "history_day_list_failed"
|
||||
DELETE_BATCH_START = "delete_batch_start"
|
||||
DELETE_BATCH_VALIDATE_FAILED = "delete_batch_validate_failed"
|
||||
DELETE_BATCH_MARKED_PENDING = "delete_batch_marked_pending"
|
||||
DELETE_BATCH_COMMIT_SUCCESS = "delete_batch_commit_success"
|
||||
DELETE_BATCH_CLEANUP_SUCCESS = "delete_batch_cleanup_success"
|
||||
DELETE_BATCH_CLEANUP_FAILED = "delete_batch_cleanup_failed"
|
||||
DELETE_BATCH_FAILED = "delete_batch_failed"
|
||||
DELETE_BATCH_ROLLBACK_FAILED = "delete_batch_rollback_failed"
|
||||
|
||||
DELETE_UPLOAD_FAILED = "delete_upload_failed"
|
||||
DELETE_UPLOAD_ROLLBACK_FAILED = "delete_upload_rollback_failed"
|
||||
DELETE_UPLOAD_CLEANUP_FAILED = "delete_upload_cleanup_failed"
|
||||
|
||||
HOT_OPENING_DELETE_PROJECT_FAILED = "hot_opening_delete_project_failed"
|
||||
HOT_OPENING_DELETE_PROJECT_ROLLBACK_FAILED = "hot_opening_delete_project_rollback_failed"
|
||||
HOT_OPENING_DELETE_PROJECT_CLEANUP_FAILED = "hot_opening_delete_project_cleanup_failed"
|
||||
|
||||
SHOT_SEGMENT_DELETE_FAILED = "shot_segment_delete_failed"
|
||||
SHOT_SEGMENT_DELETE_ROLLBACK_FAILED = "shot_segment_delete_rollback_failed"
|
||||
SHOT_SEGMENT_DELETE_CLEANUP_FAILED = "shot_segment_delete_cleanup_failed"
|
||||
|
||||
SHOT_TASK_SET_DELETE_FAILED = "shot_task_set_delete_failed"
|
||||
SHOT_TASK_SET_DELETE_ROLLBACK_FAILED = "shot_task_set_delete_rollback_failed"
|
||||
SHOT_TASK_SET_DELETE_CLEANUP_FAILED = "shot_task_set_delete_cleanup_failed"
|
||||
|
||||
UPLOAD_RESOURCE_PHYSICAL_DELETE_FAILED = "upload_resource_physical_delete_failed"
|
||||
UPLOAD_RESOURCE_CLEANUP_BATCH_FAILED = "upload_resource_cleanup_batch_failed"
|
||||
UPLOAD_RESOURCE_BACKFILL_CLI_FAILED = "upload_resource_backfill_cli_failed"
|
||||
UPLOAD_RESOURCE_BACKFILL_CLI_ROLLBACK_FAILED = "upload_resource_backfill_cli_rollback_failed"
|
||||
|
||||
|
||||
UPLOAD_RESOURCE_MODULE_LABELS: dict[str, str] = {
|
||||
UploadResourceModuleEnum.COMMON.value: "普通上传",
|
||||
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value: "爆款开头复刻",
|
||||
UploadResourceModuleEnum.SHOT_REPLICATE.value: "拆镜复刻",
|
||||
}
|
||||
|
||||
UPLOAD_RESOURCE_TYPE_LABELS: dict[str, str] = {
|
||||
UploadResourceTypeEnum.IMAGE.value: "图片",
|
||||
UploadResourceTypeEnum.VIDEO.value: "视频",
|
||||
UploadResourceTypeEnum.AUDIO.value: "音频",
|
||||
UploadResourceTypeEnum.SHOT_SEGMENT.value: "拆镜切片",
|
||||
}
|
||||
@@ -22,6 +22,7 @@ from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.models.chat_generation_task_event import ChatGenerationTaskEvent
|
||||
from app.models.chat_provider_call_log import ChatProviderCallLog
|
||||
from app.models.generated_resource import GeneratedResource
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.models.user_resource_month_stat import UserResourceMonthStat
|
||||
from app.models.user_resource_total_stat import UserResourceTotalStat
|
||||
from app.models.user_resource_capacity_config import UserResourceCapacityConfig
|
||||
@@ -44,7 +45,7 @@ __all__ = [
|
||||
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
|
||||
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
|
||||
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog",
|
||||
"GeneratedResource", "UserResourceMonthStat", "UserResourceTotalStat",
|
||||
"GeneratedResource", "UploadResource", "UserResourceMonthStat", "UserResourceTotalStat",
|
||||
"UserResourceCapacityConfig",
|
||||
"ModuleGenerationProject", "ModuleGenerationStep",
|
||||
"ShotReplicateTaskSet", "ShotReplicateSegment",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, Float, ForeignKey, Index, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class UploadResource(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""统一上传资源账本。
|
||||
|
||||
记录用户直接上传、模块上传和拆镜切片文件,不建立 ORM relationship,
|
||||
通过 source_model + source_id 进行低耦合绑定。
|
||||
"""
|
||||
|
||||
__tablename__ = "upload_resources"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("storage_path", name="uq_upload_resources_storage_path"),
|
||||
Index("ix_upload_resources_user_module_type", "user_id", "module", "resource_type", "deleted_at"),
|
||||
Index("ix_upload_resources_user_bind", "user_id", "bind_status", "deleted_at"),
|
||||
Index("ix_upload_resources_history_lookup", "user_id", "resource_type", "bind_status", "delete_policy", "deleted_at", "created_at"),
|
||||
Index("ix_upload_resources_source", "source_model", "source_id", "deleted_at"),
|
||||
Index("ix_upload_resources_url", "resource_url"),
|
||||
Index("ix_upload_resources_created", "created_at"),
|
||||
Index("ix_upload_resources_file_delete_status", "file_delete_status"),
|
||||
Index("ix_upload_resources_physical_deleted", "physical_deleted_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
|
||||
module: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
resource_type: Mapped[str] = mapped_column(String(32), index=True, nullable=False)
|
||||
|
||||
resource_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
file_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
file_ext: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
mime_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
file_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
|
||||
|
||||
duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
duration_source: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
width: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
height: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
source_model: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
source_id: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True)
|
||||
source_module: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
bind_status: Mapped[str] = mapped_column(String(32), default="pending", server_default="pending", index=True, nullable=False)
|
||||
delete_policy: Mapped[str] = mapped_column(String(32), default="user_deletable", server_default="user_deletable", index=True, nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(String(32), default="api", server_default="api", index=True, nullable=False)
|
||||
|
||||
metadata_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
capacity_released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
physical_deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
file_delete_status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default="active",
|
||||
server_default="active",
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
file_delete_error: Mapped[str | None] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
)
|
||||
@@ -23,13 +23,19 @@ class UserResourceMonthStat(Base, TimestampMixin):
|
||||
active_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
deleted_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
total_generated_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
upload_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
|
||||
|
||||
image_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
video_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
audio_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
|
||||
shot_segment_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
|
||||
|
||||
active_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
deleted_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
image_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
video_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
upload_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
audio_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
shot_segment_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
|
||||
last_recalculated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -22,13 +22,19 @@ class UserResourceTotalStat(Base, TimestampMixin):
|
||||
active_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
deleted_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
total_generated_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
upload_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
|
||||
|
||||
image_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
video_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
audio_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
|
||||
shot_segment_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
|
||||
|
||||
active_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
deleted_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
image_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
video_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
upload_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
audio_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
shot_segment_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
|
||||
last_recalculated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -43,8 +43,11 @@ class GenerationAIReference(BaseModel):
|
||||
)
|
||||
source: str | None = Field(
|
||||
None,
|
||||
description="参考素材来源。private_portrait_asset=真人素材库;为空表示普通上传文件",
|
||||
examples=["private_portrait_asset"],
|
||||
description=(
|
||||
"参考素材来源枚举:upload_resource=历史上传素材;"
|
||||
"private_portrait_asset=私域真人/虚拟素材;为空表示本次普通上传素材。"
|
||||
),
|
||||
examples=["upload_resource"],
|
||||
)
|
||||
private_asset_id: str | None = Field(
|
||||
None,
|
||||
@@ -73,9 +76,17 @@ class GenerationAIReference(BaseModel):
|
||||
)
|
||||
role: str | None = Field(
|
||||
None,
|
||||
description="参考素材角色。image 可选 first_frame/last_frame/reference_image;video 可选 reference_video;audio 可选 reference_audio",
|
||||
description=(
|
||||
"参考素材角色枚举:first_frame=首帧图;last_frame=尾帧图;"
|
||||
"reference_image=普通参考图;reference_video=普通参考视频;reference_audio=普通参考音频。"
|
||||
),
|
||||
examples=["first_frame"],
|
||||
)
|
||||
upload_resource_id: str | None = Field(
|
||||
None,
|
||||
description="历史上传素材 ID。source=upload_resource 时由 /upload-resources/history 返回,便于前端排查和去重;生成链路主要使用 url。",
|
||||
examples=["0019e0a448a23114888"],
|
||||
)
|
||||
|
||||
|
||||
class GenerationAITaskCreate(BaseModel):
|
||||
@@ -147,7 +158,11 @@ class GenerationAITaskCreate(BaseModel):
|
||||
)
|
||||
media_references: list[GenerationAIReference] | None = Field(
|
||||
None,
|
||||
description="参考素材列表。可以传图片/视频/音频参考素材;为空表示不使用参考素材",
|
||||
description=(
|
||||
"参考素材列表。支持 image/video/audio。"
|
||||
"source=upload_resource 时表示来自历史上传素材;source=private_portrait_asset 时表示来自私域真人/虚拟素材。"
|
||||
"视频和音频素材必须携带 duration,前端和后端均按 AI 创作原规则校验数量、单段 2~15 秒、总时长不超过 15 秒。"
|
||||
),
|
||||
)
|
||||
idempotency_key: str | None = Field(
|
||||
None,
|
||||
@@ -163,12 +178,12 @@ class GenerationAITaskCreate(BaseModel):
|
||||
# image params
|
||||
image_size: str | None = Field(
|
||||
None,
|
||||
description="图片分辨率档位,例如:1K、2K。仅图片生成或视频首帧参数需要使用;为空则使用引擎默认值",
|
||||
description="图片分辨率档位枚举示例:1K、2K、4K。实际可选值以 /generation-ai/engines 返回的 supported_sizes 为准。",
|
||||
examples=["2K"],
|
||||
)
|
||||
image_proportion: str | None = Field(
|
||||
None,
|
||||
description="图片比例,例如:1:1、16:9、9:16。仅图片生成或视频首帧参数需要使用;为空则使用默认值",
|
||||
description="图片比例枚举示例:1:1、16:9、9:16、4:3、3:4、21:9。实际可选值以 /generation-ai/engines 返回为准。",
|
||||
examples=["1:1"],
|
||||
)
|
||||
image_px: str | None = Field(
|
||||
@@ -180,17 +195,17 @@ class GenerationAITaskCreate(BaseModel):
|
||||
# video params
|
||||
duration: int | None = Field(
|
||||
None,
|
||||
description="视频时长,单位秒。仅视频生成使用;为空则使用默认时长",
|
||||
description="视频生成时长,单位秒。枚举范围通常为 4~15 秒,实际可选值以视频引擎 supported_durations 为准。",
|
||||
examples=[4],
|
||||
)
|
||||
aspect_ratio: str | None = Field(
|
||||
None,
|
||||
description="视频比例,例如:16:9、9:16、1:1。仅视频生成使用;为空则使用默认比例",
|
||||
description="视频比例枚举示例:16:9、4:3、1:1、3:4、9:16、21:9。实际可选值以视频引擎 supported_ratios 为准。",
|
||||
examples=["16:9"],
|
||||
)
|
||||
resolution: str | None = Field(
|
||||
None,
|
||||
description="视频分辨率,例如:480p、720p、1080p。仅视频生成使用;为空则使用默认分辨率",
|
||||
description="视频分辨率枚举示例:480p、720p、1080p。实际可选值以视频引擎 supported_resolutions 为准。",
|
||||
examples=["480p"],
|
||||
)
|
||||
|
||||
@@ -380,8 +395,9 @@ class GenerationAITaskOut(BaseModel):
|
||||
history_source: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"素材云历史来源:chat_task=AI创作,generation_record=项目生成,"
|
||||
"hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻"
|
||||
"素材云生成历史来源:chat_task=AI创作,generation_record=项目生成,"
|
||||
"hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻。"
|
||||
"注意:上传素材历史不在本接口返回,请使用 /upload-resources/history。"
|
||||
),
|
||||
)
|
||||
history_source_label: str | None = Field(None, description="素材云历史来源中文名称")
|
||||
@@ -705,8 +721,9 @@ class GenerationAIRecordHistoryItemOut(BaseModel):
|
||||
history_source: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"素材云历史来源:chat_task=AI创作,generation_record=项目生成,"
|
||||
"hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻"
|
||||
"素材云生成历史来源:chat_task=AI创作,generation_record=项目生成,"
|
||||
"hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻。"
|
||||
"注意:上传素材历史不在本接口返回,请使用 /upload-resources/history。"
|
||||
),
|
||||
)
|
||||
history_source_label: str | None = Field(None, description="素材云历史来源中文名称")
|
||||
|
||||
@@ -43,6 +43,9 @@ HOT_OPENING_STEP_IO_EXAMPLES: dict[str, dict[str, Any]] = {
|
||||
"payload": {
|
||||
"material_video_url": "https://example.com/source.mp4",
|
||||
"material_image_url": "https://example.com/product.png",
|
||||
"material_video_resource_id": "0019...",
|
||||
"material_image_resource_id": "0019...",
|
||||
"material_video_duration_seconds": 8.2,
|
||||
"source_project_name": "参考素材项目名称",
|
||||
"target_project_name": "新项目名称",
|
||||
"core_content_point": "50字以内核心内容点",
|
||||
@@ -183,6 +186,9 @@ class HotOpeningTaskCreate(BaseModel):
|
||||
"example": {
|
||||
"material_video_url": "https://example.com/source.mp4",
|
||||
"material_image_url": "https://example.com/product.png",
|
||||
"material_video_resource_id": "0019...",
|
||||
"material_image_resource_id": "0019...",
|
||||
"material_video_duration_seconds": 8.2,
|
||||
"source_project_name": "参考素材项目名称",
|
||||
"target_project_name": "新项目名称",
|
||||
"core_content_point": "突出产品能帮助用户认识附近新朋友",
|
||||
@@ -193,6 +199,10 @@ class HotOpeningTaskCreate(BaseModel):
|
||||
|
||||
material_video_url: str = Field(..., min_length=1, description="素材视频链接,参考素材,1份。由项目已有上传接口返回,本接口不负责上传,不做后端素材校验")
|
||||
material_image_url: str = Field(..., min_length=1, description="素材图片链接,新产品图片,1份。由项目已有上传接口返回,本接口不负责上传,不做后端素材校验")
|
||||
material_video_resource_id: str | None = Field(None, max_length=32, description="可选,模块上传接口返回的视频 UploadResource.id;创建项目后用于绑定资源")
|
||||
material_image_resource_id: str | None = Field(None, max_length=32, description="可选,模块上传接口返回的图片 UploadResource.id;创建项目后用于绑定资源")
|
||||
material_video_duration_seconds: float | None = Field(None, gt=0, description="可选,前端识别的视频秒数,用于上传资源元数据兜底")
|
||||
material_image_duration_seconds: float | None = Field(None, gt=0, description="兼容字段,图片一般为空")
|
||||
source_project_name: str = Field(..., min_length=1, max_length=20, description="视频素材内容项目名称")
|
||||
target_project_name: str = Field(..., min_length=1, max_length=20, description="生成项目名称")
|
||||
core_content_point: str = Field(..., min_length=1, max_length=50, description="生成的项目核心内容点,最多50字")
|
||||
@@ -224,11 +234,14 @@ class HotOpeningMaterialUpdateRequest(BaseModel):
|
||||
|
||||
material_video_url: str | None = Field(None, min_length=1, description="素材视频链接,未传则沿用旧值")
|
||||
material_image_url: str | None = Field(None, min_length=1, description="素材图片链接,未传则沿用旧值")
|
||||
material_video_resource_id: str | None = Field(None, max_length=32, description="可选,模块上传接口返回的视频 UploadResource.id")
|
||||
material_image_resource_id: str | None = Field(None, max_length=32, description="可选,模块上传接口返回的图片 UploadResource.id")
|
||||
material_video_duration_seconds: float | None = Field(None, gt=0, description="可选,前端识别的视频秒数")
|
||||
source_project_name: str | None = Field(None, min_length=1, max_length=20, description="视频素材内容项目名称,未传则沿用旧值")
|
||||
target_project_name: str | None = Field(None, min_length=1, max_length=20, description="生成项目名称,未传则沿用旧值")
|
||||
core_content_point: str | None = Field(None, min_length=1, max_length=50, description="生成项目核心内容点,最多50字,未传则沿用旧值")
|
||||
|
||||
@field_validator("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point", mode="before")
|
||||
@field_validator("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point", "material_video_resource_id", "material_image_resource_id", mode="before")
|
||||
@classmethod
|
||||
def _strip_optional(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
@@ -240,7 +253,7 @@ class HotOpeningMaterialUpdateRequest(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_at_least_one(self) -> "HotOpeningMaterialUpdateRequest":
|
||||
if not any(getattr(self, field) is not None for field in ("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point")):
|
||||
if not any(getattr(self, field) is not None for field in ("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point", "material_video_resource_id", "material_image_resource_id")):
|
||||
raise ValueError("至少需要传入一个需要修改的字段")
|
||||
return self
|
||||
|
||||
@@ -486,6 +499,9 @@ class HotOpeningDeleteOut(BaseModel):
|
||||
message: str = Field(..., description="删除结果提示")
|
||||
project_id: str = Field(..., description="被软删除的总任务项目ID")
|
||||
deleted: bool = Field(..., description="是否已软删除")
|
||||
released_size_bytes: int = Field(0, description="本次释放的用户容量占用字节数;UploadResource 真实文件在事务提交后清理")
|
||||
upload_resource_released: int = Field(0, description="本次标记删除的上传资源数量")
|
||||
pending_delete_resource_ids: list[str] = Field(default_factory=list, exclude=True, description="内部字段:commit 后待删除真实文件的 UploadResource ID")
|
||||
|
||||
|
||||
class HotOpeningSpecOut(BaseModel):
|
||||
|
||||
@@ -6,10 +6,13 @@ from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
||||
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
PrivatePortraitValidateSessionStatus,
|
||||
)
|
||||
from app.schemas.common import NaiveDatetimeOptional
|
||||
|
||||
@@ -17,141 +20,246 @@ PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS = 2
|
||||
PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS = 15
|
||||
|
||||
|
||||
_LIBRARY_TYPE_DESCRIPTIONS: dict[str, tuple[str, str]] = {
|
||||
PrivatePortraitLibraryType.REAL_PERSON.value: (
|
||||
"真人认证素材库",
|
||||
"真人素材库。创建项目时会生成火山真人认证 H5 链接,用户扫码并认证成功后,项目状态变为 active,才允许上传真人素材。",
|
||||
),
|
||||
PrivatePortraitLibraryType.AIGC_VIRTUAL.value: (
|
||||
"私域虚拟人像素材库",
|
||||
"虚拟素材库。创建项目时直接同步火山 CreateAssetGroup,GroupType=AIGC,不需要真人扫码认证。",
|
||||
),
|
||||
}
|
||||
|
||||
_PROJECT_STATUS_DESCRIPTIONS: dict[str, tuple[str, str]] = {
|
||||
PrivatePortraitProjectStatus.VALIDATING.value: ("认证中", "真人项目已创建,等待用户扫码完成真人认证。"),
|
||||
PrivatePortraitProjectStatus.ACTIVE.value: ("可用", "项目可用,允许上传素材,项目内 Active 素材可用于 AI 创作。"),
|
||||
PrivatePortraitProjectStatus.VALIDATE_FAILED.value: ("认证失败", "真人认证失败或回调结果不通过。"),
|
||||
PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value: ("远端分组创建中", "正在调用火山创建 Asset Group。"),
|
||||
PrivatePortraitProjectStatus.CREATE_GROUP_FAILED.value: ("远端分组创建失败", "火山 Asset Group 创建失败,需要重试或人工排查。"),
|
||||
PrivatePortraitProjectStatus.DELETED.value: ("已删除", "本地已软删,不再对普通前端展示。"),
|
||||
}
|
||||
|
||||
_VALIDATE_SESSION_STATUS_DESCRIPTIONS: dict[str, tuple[str, str]] = {
|
||||
PrivatePortraitValidateSessionStatus.CREATED.value: ("已创建", "认证会话已创建,前端可展示 h5_link 二维码。"),
|
||||
PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value: ("回调成功", "火山回调 resultCode=10000,认证通过,等待或已绑定远端分组。"),
|
||||
PrivatePortraitValidateSessionStatus.CALLBACK_FAILED.value: ("回调失败", "火山回调 resultCode 非成功值,认证未通过。"),
|
||||
PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value: ("分组可用", "认证成功并已获得可用 remote_group_id,项目可上传素材。"),
|
||||
PrivatePortraitValidateSessionStatus.EXPIRED.value: ("已过期", "认证会话超过有效期,需要重新创建认证会话。"),
|
||||
PrivatePortraitValidateSessionStatus.FAILED.value: ("失败", "认证会话创建、查询或回调处理失败。"),
|
||||
}
|
||||
|
||||
_ASSET_TYPE_DESCRIPTIONS: dict[str, tuple[str, str]] = {
|
||||
PrivatePortraitAssetType.IMAGE.value: ("图片", "当前开放。用于真人/虚拟图片参考素材。"),
|
||||
PrivatePortraitAssetType.VIDEO.value: (
|
||||
"视频",
|
||||
f"当前开放。必须传 video_duration,支持 {PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS}~{PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS} 秒。",
|
||||
),
|
||||
PrivatePortraitAssetType.AUDIO.value: ("音频", "火山支持但当前业务暂不开放,接口会拒绝 Audio。"),
|
||||
}
|
||||
|
||||
_ASSET_STATUS_DESCRIPTIONS: dict[str, tuple[str, str]] = {
|
||||
PrivatePortraitAssetStatus.CREATING.value: ("创建中", "本地记录已创建,正在调用火山 CreateAsset。"),
|
||||
PrivatePortraitAssetStatus.PROCESSING.value: ("处理中", "火山侧正在处理素材,暂不可用于 AI 创作。"),
|
||||
PrivatePortraitAssetStatus.ACTIVE.value: ("可用", "素材已处理完成,可在 AI 创作中作为参考素材选择。"),
|
||||
PrivatePortraitAssetStatus.FAILED.value: ("失败", "火山侧处理失败或本地同步失败。"),
|
||||
PrivatePortraitAssetStatus.LOCAL_DELETED.value: ("本地已删除", "本地已软删,等待远端删除或无需远端删除。"),
|
||||
PrivatePortraitAssetStatus.REMOTE_DELETED.value: ("远端已删除", "火山侧素材删除成功。"),
|
||||
PrivatePortraitAssetStatus.DELETE_FAILED.value: ("删除失败", "火山侧删除失败,后续可通过补偿任务重试。"),
|
||||
}
|
||||
|
||||
_REMOTE_DELETE_STATUS_DESCRIPTIONS: dict[str, tuple[str, str]] = {
|
||||
PrivatePortraitRemoteDeleteStatus.NONE.value: ("无需删除", "未触发远端删除,通常表示资源仍正常。"),
|
||||
PrivatePortraitRemoteDeleteStatus.PENDING.value: ("等待删除", "本地已软删并提交成功,远端删除任务等待执行或执行中。"),
|
||||
PrivatePortraitRemoteDeleteStatus.SUCCESS.value: ("删除成功", "火山侧远端资源已删除。"),
|
||||
PrivatePortraitRemoteDeleteStatus.FAILED.value: ("删除失败", "火山侧远端删除失败,已记录错误,后续可补偿重试。"),
|
||||
PrivatePortraitRemoteDeleteStatus.SKIPPED.value: ("跳过删除", "资源没有远端 ID 或已不需要调用远端删除。"),
|
||||
}
|
||||
|
||||
|
||||
def _enum_items(enum_values: list[str], mapping: dict[str, tuple[str, str]]) -> list["PrivatePortraitEnumItem"]:
|
||||
return [
|
||||
PrivatePortraitEnumItem(
|
||||
value=value,
|
||||
label=mapping.get(value, (value, ""))[0],
|
||||
description=mapping.get(value, (value, None))[1],
|
||||
)
|
||||
for value in enum_values
|
||||
]
|
||||
|
||||
|
||||
class PrivatePortraitEnumItem(BaseModel):
|
||||
value: str
|
||||
label: str
|
||||
description: str | None = None
|
||||
value: str = Field(..., description="枚举值,接口入参和出参均使用该值。")
|
||||
label: str = Field(..., description="枚举中文名称,用于前端展示。")
|
||||
description: str | None = Field(None, description="枚举说明,用于 OpenAPI 文档、前端提示或排查。")
|
||||
|
||||
|
||||
class PrivatePortraitEnumMetaOut(BaseModel):
|
||||
library_types: list[PrivatePortraitEnumItem]
|
||||
asset_types: list[PrivatePortraitEnumItem]
|
||||
project_statuses: list[PrivatePortraitEnumItem]
|
||||
asset_statuses: list[PrivatePortraitEnumItem]
|
||||
library_types: list[PrivatePortraitEnumItem] = Field(..., description="素材库类型枚举:real_person=真人认证素材库,aigc_virtual=私域虚拟人像素材库。")
|
||||
asset_types: list[PrivatePortraitEnumItem] = Field(..., description="素材类型枚举:Image=图片,Video=视频,Audio=音频;当前业务仅开放 Image/Video。")
|
||||
project_statuses: list[PrivatePortraitEnumItem] = Field(..., description="项目状态枚举,用于项目列表、项目详情、筛选和状态展示。")
|
||||
asset_statuses: list[PrivatePortraitEnumItem] = Field(..., description="素材状态枚举。只有 Active 素材可被 selectable-assets 返回并用于 AI 创作。")
|
||||
validate_session_statuses: list[PrivatePortraitEnumItem] = Field(..., description="真人认证会话状态枚举,用于 PC 端轮询扫码认证结果。")
|
||||
remote_delete_statuses: list[PrivatePortraitEnumItem] = Field(..., description="远端删除状态枚举。本地删除先 commit,远端删除异步执行。")
|
||||
|
||||
|
||||
class PrivatePortraitConfigOut(BaseModel):
|
||||
enabled: bool = Field(..., description="是否启用私域人像素材库。asset_limit > 0 表示启用。")
|
||||
asset_limit: int = Field(..., description="私域人像素材总量限制:真人/虚拟共用,图片/视频共用,0 表示关闭。")
|
||||
used_asset_count: int = Field(..., description="当前占用额度的素材数量。统计 creating/Processing/Active 的 Image/Video。")
|
||||
remaining_asset_count: int = Field(..., description="剩余可上传素材数量。")
|
||||
supported_asset_types: list[str] = Field(default_factory=lambda: [PrivatePortraitAssetType.IMAGE.value, PrivatePortraitAssetType.VIDEO.value], description="当前业务开放的素材类型。")
|
||||
unsupported_asset_types: list[str] = Field(default_factory=lambda: [PrivatePortraitAssetType.AUDIO.value], description="火山支持但当前业务暂不开放的素材类型。")
|
||||
# 兼容旧前端,后续确认无引用后可移除。
|
||||
image_limit: int | None = Field(None, description="兼容旧字段:请改用 asset_limit。")
|
||||
used_image_count: int | None = Field(None, description="兼容旧字段:请改用 used_asset_count。")
|
||||
remaining_image_count: int | None = Field(None, description="兼容旧字段:请改用 remaining_asset_count。")
|
||||
enabled: bool = Field(..., description="是否启用私域人像素材库。asset_limit > 0 表示启用;asset_limit = 0 表示关闭。")
|
||||
asset_limit: int = Field(..., description="私域素材总量限制。真人/虚拟共用,图片/视频共用;0 表示关闭私域素材模块。")
|
||||
used_asset_count: int = Field(..., description="当前已占用额度的素材数量。统计真人+虚拟、Image+Video,状态包含 creating/Processing/Active。")
|
||||
remaining_asset_count: int = Field(..., description="剩余可上传素材数量。计算方式:max(asset_limit - used_asset_count, 0)。")
|
||||
supported_asset_types: list[str] = Field(
|
||||
default_factory=lambda: [PrivatePortraitAssetType.IMAGE.value, PrivatePortraitAssetType.VIDEO.value],
|
||||
description="当前业务开放的素材类型枚举值。固定为 Image / Video。",
|
||||
)
|
||||
unsupported_asset_types: list[str] = Field(
|
||||
default_factory=lambda: [PrivatePortraitAssetType.AUDIO.value],
|
||||
description="火山支持但当前业务暂不开放的素材类型。当前为 Audio。",
|
||||
)
|
||||
image_limit: int | None = Field(None, description="兼容旧前端字段:旧图片额度字段。新前端请改用 asset_limit。")
|
||||
used_image_count: int | None = Field(None, description="兼容旧前端字段:旧图片已用数量。新前端请改用 used_asset_count。")
|
||||
remaining_image_count: int | None = Field(None, description="兼容旧前端字段:旧图片剩余额度。新前端请改用 remaining_asset_count。")
|
||||
|
||||
|
||||
class PrivatePortraitAdminConfigUpdate(BaseModel):
|
||||
private_portrait_asset_limit: int = Field(..., ge=0, le=9999, description="私域人像素材总量限制。0 表示关闭;>0 表示启用并限制真人/虚拟、图片/视频素材总量。")
|
||||
private_portrait_asset_limit: int = Field(
|
||||
...,
|
||||
ge=0,
|
||||
le=9999,
|
||||
description="用户私域素材总量限制。0 表示关闭;>0 表示启用并限制真人/虚拟、图片/视频素材总量。",
|
||||
)
|
||||
|
||||
|
||||
class PrivatePortraitProjectCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=128, description="项目组名称。")
|
||||
description: str | None = Field(None, max_length=2000, description="项目组描述。")
|
||||
callback_redirect_url: str | None = Field(None, description="仅真人认证使用:认证完成后的手机端提示页地址。")
|
||||
name: str = Field(..., min_length=1, max_length=128, description="真人认证素材项目组名称。前端展示用,不直接作为火山 ProjectName。")
|
||||
description: str | None = Field(None, max_length=2000, description="真人认证素材项目组描述,前端展示和管理备注用。")
|
||||
callback_redirect_url: str | None = Field(
|
||||
None,
|
||||
description="真人认证完成后的手机端跳转地址。为空时使用后端默认回调页;PC 端仍应通过 validate_session 轮询最终状态。",
|
||||
)
|
||||
|
||||
|
||||
class PrivatePortraitVirtualProjectCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=128, description="虚拟人像素材项目组名称。创建后会同步创建火山 Asset Group。")
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
description="虚拟人像素材项目组名称。创建后会同步火山 CreateAssetGroup,GroupType=AIGC。",
|
||||
)
|
||||
description: str | None = Field(None, max_length=2000, description="虚拟人像素材项目组描述,会同步到火山 Asset Group。")
|
||||
|
||||
|
||||
class PrivatePortraitProjectUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=128, description="项目组名称。")
|
||||
description: str | None = Field(None, max_length=2000, description="项目组描述。")
|
||||
status: str | None = Field(None, description="项目状态。普通前端不建议手动变更,仅管理/排查使用。")
|
||||
name: str | None = Field(None, min_length=1, max_length=128, description="项目组名称。仅修改本地展示名称和必要远端分组名称。")
|
||||
description: str | None = Field(None, max_length=2000, description="项目组描述。用于前端展示和管理备注。")
|
||||
status: str | None = Field(
|
||||
None,
|
||||
description="项目状态。可选值:validating、active、validate_failed、creating_remote_group、create_group_failed、deleted。普通前端不建议手动变更。",
|
||||
)
|
||||
|
||||
|
||||
class PrivatePortraitProjectOut(BaseModel):
|
||||
id: str
|
||||
user_id: str | None = None
|
||||
library_type: str = Field(default=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
name: str
|
||||
name_slug: str | None = None
|
||||
remote_project_name: str | None = None
|
||||
description: str | None = None
|
||||
status: str
|
||||
asset_group_count: int = 0
|
||||
asset_count: int = 0
|
||||
image_asset_count: int = 0
|
||||
video_asset_count: int = 0
|
||||
active_asset_count: int = 0
|
||||
active_image_asset_count: int = 0
|
||||
active_video_asset_count: int = 0
|
||||
last_used_at: NaiveDatetimeOptional = None
|
||||
created_at: NaiveDatetimeOptional = None
|
||||
updated_at: NaiveDatetimeOptional = None
|
||||
id: str = Field(..., description="本地私域项目 ID。后续查询、更新、删除、上传素材均使用该 ID。")
|
||||
user_id: str | None = Field(None, description="项目所属用户 ID。普通用户接口一般返回当前用户 ID;管理接口用于定位用户。")
|
||||
library_type: str = Field(default=PrivatePortraitLibraryType.REAL_PERSON.value, description="素材库类型:real_person=真人认证素材库,aigc_virtual=私域虚拟素材库。")
|
||||
name: str = Field(..., description="项目组名称,前端展示用。")
|
||||
name_slug: str | None = Field(None, description="项目名称归一化值,兼容旧逻辑或排查使用。")
|
||||
remote_project_name: str | None = Field(None, description=f"火山侧 ProjectName 快照。当前固定为 {PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME}。")
|
||||
description: str | None = Field(None, description="项目组描述。")
|
||||
status: str = Field(..., description="项目状态。active 表示可上传素材;真人项目 validating 表示等待扫码认证。")
|
||||
asset_group_count: int = Field(0, description="项目下本地素材组数量。真人一般认证成功后为 1,虚拟创建项目后为 1。")
|
||||
asset_count: int = Field(0, description="项目下素材总数,包含图片和视频。")
|
||||
image_asset_count: int = Field(0, description="项目下图片素材数量。")
|
||||
video_asset_count: int = Field(0, description="项目下视频素材数量。")
|
||||
active_asset_count: int = Field(0, description="项目下 Active 可用素材总数。")
|
||||
active_image_asset_count: int = Field(0, description="项目下 Active 可用图片素材数量。")
|
||||
active_video_asset_count: int = Field(0, description="项目下 Active 可用视频素材数量。")
|
||||
last_used_at: NaiveDatetimeOptional = Field(None, description="最近被 AI 创作引用的时间。为空表示尚未使用。")
|
||||
created_at: NaiveDatetimeOptional = Field(None, description="本地项目创建时间。")
|
||||
updated_at: NaiveDatetimeOptional = Field(None, description="本地项目最后更新时间。")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PrivatePortraitProjectListOut(BaseModel):
|
||||
items: list[PrivatePortraitProjectOut]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
items: list[PrivatePortraitProjectOut] = Field(..., description="项目列表。真人接口只返回 real_person,虚拟接口只返回 aigc_virtual。")
|
||||
total: int = Field(..., description="符合筛选条件的项目总数。")
|
||||
page: int = Field(..., description="当前页码,从 1 开始。")
|
||||
page_size: int = Field(..., description="每页数量。")
|
||||
|
||||
|
||||
class PrivatePortraitValidateSessionCreate(BaseModel):
|
||||
callback_redirect_url: str | None = Field(None, description="认证完成后前端要跳转的页面。为空时使用后端默认回调页。")
|
||||
callback_redirect_url: str | None = Field(
|
||||
None,
|
||||
description="真人认证完成后手机端要跳转的页面。为空时使用后端默认回调页;PC 端应继续轮询 validate-session。",
|
||||
)
|
||||
|
||||
|
||||
class PrivatePortraitValidateSessionOut(BaseModel):
|
||||
id: str
|
||||
user_id: str | None = None
|
||||
project_id: str
|
||||
byted_token: str | None = None
|
||||
h5_link: str | None = None
|
||||
callback_url: str | None = None
|
||||
result_code: str | None = None
|
||||
algorithm_base_resp_code: str | None = None
|
||||
verify_type: str | None = None
|
||||
status: str
|
||||
remote_group_id: str | None = None
|
||||
remote_project_name: str | None = None
|
||||
expired_at: NaiveDatetimeOptional = None
|
||||
error_message: str | None = None
|
||||
created_at: NaiveDatetimeOptional = None
|
||||
updated_at: NaiveDatetimeOptional = None
|
||||
id: str = Field(..., description="本地认证会话 ID。PC 端轮询认证状态时使用。")
|
||||
user_id: str | None = Field(None, description="认证会话所属用户 ID。")
|
||||
project_id: str = Field(..., description="认证会话绑定的本地真人项目 ID。")
|
||||
byted_token: str | None = Field(None, description="火山认证 token,排查用。前端一般不直接展示。")
|
||||
h5_link: str | None = Field(None, description="火山真人认证 H5 链接。前端应转二维码供手机扫码。")
|
||||
callback_url: str | None = Field(None, description="后端传给火山的回调地址。认证完成后火山会请求该地址。")
|
||||
result_code: str | None = Field(None, description="火山回调 resultCode。10000 表示成功,其他值表示失败或未通过。")
|
||||
algorithm_base_resp_code: str | None = Field(None, description="火山算法返回码,排查认证失败原因用。")
|
||||
verify_type: str | None = Field(None, description="火山认证类型。当前真人认证使用 real_time。")
|
||||
status: str = Field(..., description="认证会话状态:created、callback_success、callback_failed、group_active、expired、failed。")
|
||||
remote_group_id: str | None = Field(None, description="认证成功后火山返回的 Asset Group ID。后续上传真人素材会绑定该分组。")
|
||||
remote_project_name: str | None = Field(None, description=f"火山侧 ProjectName 快照。当前固定为 {PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME}。")
|
||||
expired_at: NaiveDatetimeOptional = Field(None, description="认证会话过期时间。过期后需要重新创建认证会话。")
|
||||
error_message: str | None = Field(None, description="认证失败、回调失败或查询失败的错误信息。")
|
||||
created_at: NaiveDatetimeOptional = Field(None, description="认证会话创建时间。")
|
||||
updated_at: NaiveDatetimeOptional = Field(None, description="认证会话最后更新时间。")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PrivatePortraitProjectCreateWithValidateOut(BaseModel):
|
||||
project: PrivatePortraitProjectOut
|
||||
validate_session: PrivatePortraitValidateSessionOut
|
||||
project: PrivatePortraitProjectOut = Field(..., description="创建成功的真人项目数据。初始状态通常为 validating。")
|
||||
validate_session: PrivatePortraitValidateSessionOut = Field(..., description="随项目创建一起生成的真人认证会话。前端用 h5_link 展示二维码。")
|
||||
poll_interval_ms: int = Field(default=2000, description="PC 端轮询认证状态的建议间隔,单位毫秒。")
|
||||
|
||||
|
||||
class PrivatePortraitAssetGroupOut(BaseModel):
|
||||
id: str
|
||||
user_id: str | None = None
|
||||
project_id: str
|
||||
library_type: str
|
||||
remote_group_id: str
|
||||
remote_group_name: str | None = None
|
||||
remote_project_name: str
|
||||
group_type: str
|
||||
status: str
|
||||
remote_delete_status: str
|
||||
remote_deleted_at: NaiveDatetimeOptional = None
|
||||
remote_delete_error: str | None = None
|
||||
created_at: NaiveDatetimeOptional = None
|
||||
updated_at: NaiveDatetimeOptional = None
|
||||
id: str = Field(..., description="本地素材组 ID。")
|
||||
user_id: str | None = Field(None, description="素材组所属用户 ID。")
|
||||
project_id: str = Field(..., description="素材组所属本地项目 ID。")
|
||||
library_type: str = Field(..., description="素材库类型:real_person=真人认证素材库,aigc_virtual=私域虚拟素材库。")
|
||||
remote_group_id: str = Field(..., description="火山 Asset Group ID。")
|
||||
remote_group_name: str | None = Field(None, description="火山 Asset Group 名称。")
|
||||
remote_project_name: str = Field(..., description=f"火山侧 ProjectName。当前固定为 {PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME}。")
|
||||
group_type: str = Field(..., description="火山分组类型。真人为 LivenessFace,虚拟为 AIGC。")
|
||||
status: str = Field(..., description="素材组本地状态:creating、active、local_deleted、remote_deleted、delete_failed、failed。")
|
||||
remote_delete_status: str = Field(..., description="远端删除状态:none、pending、success、failed、skipped。")
|
||||
remote_deleted_at: NaiveDatetimeOptional = Field(None, description="远端删除成功时间。")
|
||||
remote_delete_error: str | None = Field(None, description="远端删除失败错误信息。")
|
||||
created_at: NaiveDatetimeOptional = Field(None, description="素材组创建时间。")
|
||||
updated_at: NaiveDatetimeOptional = Field(None, description="素材组最后更新时间。")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PrivatePortraitAssetCreate(BaseModel):
|
||||
url: str = Field(..., min_length=1, description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换公网地址后调用火山 CreateAsset。")
|
||||
asset_type: str = Field(default=PrivatePortraitAssetType.IMAGE.value, description="素材类型。当前业务仅开放 Image / Video,Audio 暂不开放。")
|
||||
name: str | None = Field(None, max_length=256, description="素材名称,仅用于检索和管理。")
|
||||
video_duration: float | None = Field(None, ge=0, description="视频素材时长,单位秒。图片可为空。")
|
||||
video_cover_url: str | None = Field(None, description="视频封面预览地址。图片可为空。")
|
||||
file_size: int | None = Field(None, ge=0, description="文件大小,字节。")
|
||||
mime_type: str | None = Field(None, max_length=128, description="素材 MIME 类型。")
|
||||
url: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换为公网地址后调用火山 CreateAsset。",
|
||||
)
|
||||
asset_type: str = Field(
|
||||
default=PrivatePortraitAssetType.IMAGE.value,
|
||||
description="素材类型枚举:Image=图片,Video=视频,Audio=音频。当前业务仅开放 Image / Video,Audio 会被拒绝。",
|
||||
)
|
||||
name: str | None = Field(None, max_length=256, description="素材名称。用于前端展示、检索和管理备注;不影响远端素材处理。")
|
||||
video_duration: float | None = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description=f"视频素材时长,单位秒。asset_type=Video 时必填,范围 {PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS}~{PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS} 秒;图片可为空。",
|
||||
)
|
||||
video_cover_url: str | None = Field(None, description="视频封面预览地址。asset_type=Video 时建议传入;图片可为空。")
|
||||
file_size: int | None = Field(None, ge=0, description="文件大小,单位字节。用于前端展示和排查。")
|
||||
mime_type: str | None = Field(None, max_length=128, description="素材 MIME 类型,例如 image/png、video/mp4。用于前端展示和排查。")
|
||||
|
||||
@field_validator("asset_type")
|
||||
@classmethod
|
||||
@@ -177,100 +285,101 @@ class PrivatePortraitAssetCreate(BaseModel):
|
||||
|
||||
|
||||
class PrivatePortraitAssetOut(BaseModel):
|
||||
id: str
|
||||
user_id: str | None = None
|
||||
project_id: str
|
||||
project_name: str | None = None
|
||||
group_id: str
|
||||
library_type: str
|
||||
remote_group_id: str
|
||||
remote_asset_id: str | None = None
|
||||
remote_project_name: str | None = None
|
||||
asset_type: str
|
||||
name: str | None = None
|
||||
source_url: str
|
||||
preview_url: str | None = None
|
||||
display_url: str | None = None
|
||||
provider_url: str | None = None
|
||||
remote_url: str | None = None
|
||||
remote_url_expired_at: NaiveDatetimeOptional = None
|
||||
video_duration: float | None = None
|
||||
video_cover_url: str | None = None
|
||||
file_size: int | None = None
|
||||
mime_type: str | None = None
|
||||
status: str
|
||||
moderation: Any = None
|
||||
last_poll_at: NaiveDatetimeOptional = None
|
||||
next_poll_at: NaiveDatetimeOptional = None
|
||||
poll_count: int = 0
|
||||
remote_delete_status: str
|
||||
remote_deleted_at: NaiveDatetimeOptional = None
|
||||
remote_delete_error: str | None = None
|
||||
error_message: str | None = None
|
||||
created_at: NaiveDatetimeOptional = None
|
||||
updated_at: NaiveDatetimeOptional = None
|
||||
id: str = Field(..., description="本地素材 ID。删除、同步、详情查询均使用该 ID。")
|
||||
user_id: str | None = Field(None, description="素材所属用户 ID。")
|
||||
project_id: str = Field(..., description="素材所属项目 ID。")
|
||||
project_name: str | None = Field(None, description="素材所属项目名称。列表接口会尽量回填,详情接口会单独查询回填。")
|
||||
group_id: str = Field(..., description="本地素材组 ID。")
|
||||
library_type: str = Field(..., description="素材库类型:real_person=真人认证素材库,aigc_virtual=私域虚拟素材库。")
|
||||
remote_group_id: str = Field(..., description="火山 Asset Group ID。")
|
||||
remote_asset_id: str | None = Field(None, description="火山 Asset ID。创建成功后回填。")
|
||||
remote_project_name: str | None = Field(None, description=f"火山侧 ProjectName 快照。当前固定为 {PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME}。")
|
||||
asset_type: str = Field(..., description="素材类型:Image=图片,Video=视频。Audio 当前不会返回给普通业务。")
|
||||
name: str | None = Field(None, description="素材名称。")
|
||||
source_url: str = Field(..., description="本地原始素材 URL。用于回显和排查,生成时通常使用 display_url/provider_url。")
|
||||
preview_url: str | None = Field(None, description="前端预览 URL。图片/视频都优先使用该字段展示缩略或预览。")
|
||||
display_url: str | None = Field(None, description="前端可访问展示 URL。用于素材列表、素材云或 AI 创作选择器展示。")
|
||||
provider_url: str | None = Field(None, description="提供给生成服务使用的 URL。可能是本地签名 URL 或远端可访问 URL。")
|
||||
remote_url: str | None = Field(None, description="火山侧返回的素材 URL。可能有过期时间,仅排查或兜底使用。")
|
||||
remote_url_expired_at: NaiveDatetimeOptional = Field(None, description="remote_url 过期时间。过期后应重新同步素材状态或使用 display_url/provider_url。")
|
||||
video_duration: float | None = Field(None, description="视频素材时长,单位秒。图片为空。")
|
||||
video_cover_url: str | None = Field(None, description="视频封面 URL。图片为空。")
|
||||
file_size: int | None = Field(None, description="素材文件大小,单位字节。")
|
||||
mime_type: str | None = Field(None, description="素材 MIME 类型。")
|
||||
status: str = Field(..., description="素材状态:creating、Processing、Active、Failed、local_deleted、remote_deleted、delete_failed。只有 Active 可用于 AI 创作。")
|
||||
moderation: Any = Field(None, description="火山审核或处理返回的原始补充信息。结构可能随火山返回变化,前端一般不依赖该字段。")
|
||||
last_poll_at: NaiveDatetimeOptional = Field(None, description="最近一次同步/轮询火山素材状态的时间。")
|
||||
next_poll_at: NaiveDatetimeOptional = Field(None, description="下一次计划同步/轮询火山素材状态的时间。")
|
||||
poll_count: int = Field(0, description="素材状态轮询次数。用于排查处理超时。")
|
||||
remote_delete_status: str = Field(..., description="远端删除状态:none、pending、success、failed、skipped。")
|
||||
remote_deleted_at: NaiveDatetimeOptional = Field(None, description="远端素材删除成功时间。")
|
||||
remote_delete_error: str | None = Field(None, description="远端素材删除失败错误信息。")
|
||||
error_message: str | None = Field(None, description="创建、同步、处理或删除过程中的错误信息。")
|
||||
created_at: NaiveDatetimeOptional = Field(None, description="素材创建时间。")
|
||||
updated_at: NaiveDatetimeOptional = Field(None, description="素材最后更新时间。")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PrivatePortraitAssetListOut(BaseModel):
|
||||
items: list[PrivatePortraitAssetOut]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
items: list[PrivatePortraitAssetOut] = Field(..., description="素材列表。包含图片/视频,是否可用以 status=Active 为准。")
|
||||
total: int = Field(..., description="符合筛选条件的素材总数。")
|
||||
page: int = Field(..., description="当前页码,从 1 开始。")
|
||||
page_size: int = Field(..., description="每页数量。")
|
||||
|
||||
|
||||
class PrivatePortraitSelectableAssetOut(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
project_name: str
|
||||
library_type: str
|
||||
name: str | None = None
|
||||
asset_type: str
|
||||
preview_url: str | None = None
|
||||
display_url: str | None = None
|
||||
provider_url: str | None = None
|
||||
video_duration: float | None = None
|
||||
video_cover_url: str | None = None
|
||||
status: str = PrivatePortraitAssetStatus.ACTIVE.value
|
||||
created_at: NaiveDatetimeOptional = None
|
||||
id: str = Field(..., description="本地素材 ID。AI 创作选择后可作为 private_asset_id / private_portrait_asset 引用。")
|
||||
project_id: str = Field(..., description="素材所属项目 ID。")
|
||||
project_name: str = Field(..., description="素材所属项目名称。")
|
||||
library_type: str = Field(..., description="素材库类型:real_person=真人认证素材库,aigc_virtual=私域虚拟素材库。")
|
||||
name: str | None = Field(None, description="素材名称。")
|
||||
asset_type: str = Field(..., description="素材类型:Image=图片,Video=视频。")
|
||||
preview_url: str | None = Field(None, description="前端选择器预览 URL。")
|
||||
display_url: str | None = Field(None, description="前端展示 URL。")
|
||||
provider_url: str | None = Field(None, description="传给 AI 创作生成接口的可访问 URL。")
|
||||
video_duration: float | None = Field(None, description="视频素材时长,单位秒。图片为空。AI 创作会用它做视频总时长校验。")
|
||||
video_cover_url: str | None = Field(None, description="视频封面 URL。图片为空。")
|
||||
status: str = Field(default=PrivatePortraitAssetStatus.ACTIVE.value, description="固定返回 Active。该接口只返回可用于 AI 创作的素材。")
|
||||
created_at: NaiveDatetimeOptional = Field(None, description="素材创建时间。")
|
||||
|
||||
|
||||
class PrivatePortraitSelectableAssetListOut(BaseModel):
|
||||
items: list[PrivatePortraitSelectableAssetOut]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
items: list[PrivatePortraitSelectableAssetOut] = Field(..., description="可用于 AI 创作选择器的素材列表。只包含 Active 的 Image/Video。")
|
||||
total: int = Field(..., description="符合筛选条件的可选素材总数。")
|
||||
page: int = Field(..., description="当前页码,从 1 开始。")
|
||||
page_size: int = Field(..., description="每页数量。")
|
||||
|
||||
|
||||
class PrivatePortraitDeleteOut(BaseModel):
|
||||
success: bool = True
|
||||
remote_delete_status: str
|
||||
success: bool = Field(True, description="本地删除是否成功。接口返回时表示本地软删已提交成功。")
|
||||
remote_delete_status: str = Field(..., description="远端删除状态。通常返回 pending,表示远端删除任务已投递或等待补偿。")
|
||||
|
||||
|
||||
class PrivatePortraitAdminStatsOut(BaseModel):
|
||||
total_projects: int = 0
|
||||
total_assets: int = 0
|
||||
image_assets: int = 0
|
||||
video_assets: int = 0
|
||||
active_assets: int = 0
|
||||
processing_assets: int = 0
|
||||
failed_assets: int = 0
|
||||
real_person_assets: int = 0
|
||||
virtual_assets: int = 0
|
||||
total_projects: int = Field(0, description="项目总数,包含真人和虚拟项目。")
|
||||
total_assets: int = Field(0, description="素材总数,包含真人/虚拟、图片/视频。")
|
||||
image_assets: int = Field(0, description="图片素材数量。")
|
||||
video_assets: int = Field(0, description="视频素材数量。")
|
||||
active_assets: int = Field(0, description="Active 可用素材数量。")
|
||||
processing_assets: int = Field(0, description="Processing 处理中素材数量。")
|
||||
failed_assets: int = Field(0, description="Failed 失败素材数量。")
|
||||
real_person_assets: int = Field(0, description="真人素材数量,library_type=real_person。")
|
||||
virtual_assets: int = Field(0, description="虚拟素材数量,library_type=aigc_virtual。")
|
||||
|
||||
|
||||
def build_private_portrait_enum_meta() -> PrivatePortraitEnumMetaOut:
|
||||
return PrivatePortraitEnumMetaOut(
|
||||
library_types=[
|
||||
PrivatePortraitEnumItem(value=PrivatePortraitLibraryType.REAL_PERSON.value, label="真人认证素材库", description="需要用户扫码完成真人授权认证后才能上传素材。"),
|
||||
PrivatePortraitEnumItem(value=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, label="私域虚拟人像素材库", description="通过火山 CreateAssetGroup/CreateAsset 入库的虚拟人像素材。"),
|
||||
],
|
||||
asset_types=[
|
||||
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.IMAGE.value, label="图片", description="当前开放。"),
|
||||
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.VIDEO.value, label="视频", description="当前开放,处理时间通常比图片更长。"),
|
||||
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.AUDIO.value, label="音频", description="火山支持但当前业务暂不开放。"),
|
||||
],
|
||||
project_statuses=[PrivatePortraitEnumItem(value=item.value, label=item.value) for item in PrivatePortraitProjectStatus],
|
||||
asset_statuses=[PrivatePortraitEnumItem(value=item.value, label=item.value) for item in PrivatePortraitAssetStatus],
|
||||
library_types=_enum_items(
|
||||
[PrivatePortraitLibraryType.REAL_PERSON.value, PrivatePortraitLibraryType.AIGC_VIRTUAL.value],
|
||||
_LIBRARY_TYPE_DESCRIPTIONS,
|
||||
),
|
||||
asset_types=_enum_items(
|
||||
[PrivatePortraitAssetType.IMAGE.value, PrivatePortraitAssetType.VIDEO.value, PrivatePortraitAssetType.AUDIO.value],
|
||||
_ASSET_TYPE_DESCRIPTIONS,
|
||||
),
|
||||
project_statuses=_enum_items([item.value for item in PrivatePortraitProjectStatus], _PROJECT_STATUS_DESCRIPTIONS),
|
||||
asset_statuses=_enum_items([item.value for item in PrivatePortraitAssetStatus], _ASSET_STATUS_DESCRIPTIONS),
|
||||
validate_session_statuses=_enum_items([item.value for item in PrivatePortraitValidateSessionStatus], _VALIDATE_SESSION_STATUS_DESCRIPTIONS),
|
||||
remote_delete_statuses=_enum_items([item.value for item in PrivatePortraitRemoteDeleteStatus], _REMOTE_DELETE_STATUS_DESCRIPTIONS),
|
||||
)
|
||||
|
||||
@@ -227,6 +227,7 @@ class ShotReplicateMaterialUpdateRequest(BaseModel):
|
||||
)
|
||||
|
||||
material_image_url: str | None = Field(None, min_length=1, description="素材图片链接,未传则沿用旧值。用于新产品/目标素材图片,来自已有上传接口")
|
||||
material_image_resource_id: str | None = Field(None, max_length=32, description="可选,拆镜模块上传接口返回的图片 UploadResource.id")
|
||||
source_project_name: str | None = Field(None, min_length=1, max_length=20, description="视频素材内容项目名称,未传则沿用旧值")
|
||||
target_project_name: str | None = Field(None, min_length=1, max_length=20, description="生成项目名称,未传则沿用旧值")
|
||||
core_content_point: str | None = Field(None, min_length=1, max_length=50, description="生成项目核心内容点,最多50字,未传则沿用旧值")
|
||||
@@ -243,7 +244,7 @@ class ShotReplicateMaterialUpdateRequest(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_at_least_one(self) -> "ShotReplicateMaterialUpdateRequest":
|
||||
if not any(getattr(self, field) is not None for field in ("material_image_url", "source_project_name", "target_project_name", "core_content_point")):
|
||||
if not any(getattr(self, field) is not None for field in ("material_image_url", "source_project_name", "target_project_name", "core_content_point", "material_image_resource_id")):
|
||||
raise ValueError("至少需要传入一个需要修改的字段")
|
||||
return self
|
||||
|
||||
@@ -482,9 +483,11 @@ class ShotReplicateActionOut(BaseModel):
|
||||
|
||||
class ShotReplicateDeleteOut(BaseModel):
|
||||
message: str = Field(..., description="删除结果提示")
|
||||
project_id: str = Field(..., description="被软删除的总任务项目ID")
|
||||
project_id: str = Field(..., description="被软删除的内部拆镜复刻 ModuleGenerationProject ID")
|
||||
deleted: bool = Field(..., description="是否已软删除")
|
||||
released_size_bytes: int = Field(0, description="本次软删释放的用户容量占用字节数;不删除物理文件")
|
||||
released_size_bytes: int = Field(0, description="本次软删释放的用户容量占用字节数;UploadResource 真实文件在事务提交后清理")
|
||||
upload_resource_released: int = Field(0, description="本次标记删除的上传资源数量")
|
||||
pending_delete_resource_ids: list[str] = Field(default_factory=list, exclude=True, description="内部字段:commit 后待删除真实文件的 UploadResource ID")
|
||||
|
||||
|
||||
class ShotSegmentDeleteOut(BaseModel):
|
||||
@@ -493,7 +496,20 @@ class ShotSegmentDeleteOut(BaseModel):
|
||||
task_set_id: str = Field(..., description="所属拆镜总任务集ID")
|
||||
deleted: bool = Field(..., description="是否已软删除")
|
||||
deleted_module_project_id: str | None = Field(None, description="联动软删除的拆镜复刻项目ID;没有关联项目时为空")
|
||||
released_size_bytes: int = Field(0, description="本次释放的用户容量占用字节数;只释放数据账本,不删除物理文件")
|
||||
released_size_bytes: int = Field(0, description="本次释放的用户容量占用字节数;UploadResource 真实文件在事务提交后清理")
|
||||
upload_resource_released: int = Field(0, description="本次标记删除的上传资源数量")
|
||||
pending_delete_resource_ids: list[str] = Field(default_factory=list, exclude=True, description="内部字段:commit 后待删除真实文件的 UploadResource ID")
|
||||
|
||||
|
||||
class ShotTaskSetDeleteOut(BaseModel):
|
||||
message: str = Field(..., description="删除结果提示")
|
||||
task_set_id: str = Field(..., description="被软删除的拆镜任务集ID")
|
||||
deleted: bool = Field(..., description="是否已软删除")
|
||||
deleted_segment_count: int = Field(0, description="本次软删除的片段数量")
|
||||
deleted_module_project_count: int = Field(0, description="本次联动软删除的内部复刻项目数量")
|
||||
released_size_bytes: int = Field(0, description="本次释放的用户容量占用字节数;UploadResource 真实文件在事务提交后清理")
|
||||
upload_resource_released: int = Field(0, description="本次标记删除的上传资源数量")
|
||||
pending_delete_resource_ids: list[str] = Field(default_factory=list, exclude=True, description="内部字段:commit 后待删除真实文件的 UploadResource ID")
|
||||
|
||||
|
||||
|
||||
@@ -568,6 +584,7 @@ class ShotTaskSetCreate(BaseModel):
|
||||
"example": {
|
||||
"video_url": "/uploads/2026/06/11/demo.mp4",
|
||||
"video_duration_seconds": 31.42,
|
||||
"video_resource_id": "0019...",
|
||||
"title": "游戏视频拆镜",
|
||||
"idempotency_key": "frontend-shot-task-001",
|
||||
}
|
||||
@@ -576,6 +593,7 @@ class ShotTaskSetCreate(BaseModel):
|
||||
|
||||
video_url: str = Field(..., min_length=1, description="已有上传接口返回的视频链接。必须能反解到 storage/uploads 下文件")
|
||||
video_duration_seconds: float = Field(..., gt=0, description="前端获取的视频时长秒数,允许浮点;后端会用 ffprobe 校验并以后端真实时长为准")
|
||||
video_resource_id: str | None = Field(None, max_length=32, description="可选,拆镜模块上传接口返回的视频 UploadResource.id;创建任务集后用于绑定资源")
|
||||
title: str | None = Field(None, max_length=160, description="拆镜总任务标题")
|
||||
idempotency_key: str | None = Field(None, max_length=64, description="创建总任务幂等键")
|
||||
|
||||
@@ -700,6 +718,28 @@ class ShotSegmentListOut(BaseModel):
|
||||
items: list[ShotSegmentOut] = Field(default_factory=list, description="拆镜片段列表")
|
||||
|
||||
|
||||
|
||||
|
||||
class ShotReanalyzeRequest(BaseModel):
|
||||
force: bool = Field(False, description="是否强制重跑;默认 false。当前仅允许失败/待处理数据重试,已完成数据不建议强制覆盖")
|
||||
reason: str | None = Field(None, max_length=200, description="再次分析原因,会写入模块日志")
|
||||
|
||||
@field_validator("reason", mode="before")
|
||||
@classmethod
|
||||
def _strip_reason(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = str(value).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
class ShotReanalyzeOut(BaseModel):
|
||||
message: str = Field(..., description="操作结果提示")
|
||||
task_set_id: str | None = Field(None, description="拆镜总任务集ID")
|
||||
segment_id: str | None = Field(None, description="拆镜片段ID")
|
||||
analysis_status: str = Field(..., description="重置后的分析状态")
|
||||
celery_task_name: str = Field(..., description="已投递或待投递的 Celery 任务名")
|
||||
|
||||
class ShotSplitByAIOut(BaseModel):
|
||||
task_set_id: str = Field(..., description="拆镜总任务集ID")
|
||||
status: str = Field(..., description="总任务状态:pending_analysis/analyzing/analysis_completed/analysis_failed/splitting/split_completed/partial_failed/failed/deleted")
|
||||
@@ -720,6 +760,7 @@ class ShotSegmentReplicationCreateRequest(BaseModel):
|
||||
"target_project_name": "新产品推广视频",
|
||||
"core_content_point": "突出产品附近交友和快速脱单",
|
||||
"material_image_url": "/uploads/2026/06/11/product.png",
|
||||
"material_image_resource_id": "0019...",
|
||||
"idempotency_key": "optional-key",
|
||||
}
|
||||
}
|
||||
@@ -728,9 +769,10 @@ class ShotSegmentReplicationCreateRequest(BaseModel):
|
||||
target_project_name: str = Field(..., min_length=1, max_length=20, description="生成项目名称,最多20字;会写入 module_generation_projects.title 和第1步 material_input")
|
||||
core_content_point: str = Field(..., min_length=1, max_length=50, description="生成项目核心内容点,最多50字;用于后续图片 AI 提词和视频 AI 提词")
|
||||
material_image_url: str = Field(..., min_length=1, description="新产品/目标素材图片链接,来自已有上传接口;作为图片生成参考素材")
|
||||
material_image_resource_id: str | None = Field(None, max_length=32, description="可选,拆镜模块上传接口返回的图片 UploadResource.id;创建复刻项目后用于绑定资源")
|
||||
idempotency_key: str | None = Field(None, max_length=64, description="创建 ModuleGenerationProject 幂等键;为空时后端可按业务生成或不使用")
|
||||
|
||||
@field_validator("target_project_name", "core_content_point", "material_image_url", "idempotency_key", mode="before")
|
||||
@field_validator("target_project_name", "core_content_point", "material_image_url", "material_image_resource_id", "idempotency_key", mode="before")
|
||||
@classmethod
|
||||
def _strip_text(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
|
||||
@@ -22,6 +22,7 @@ class JoinRequestOut(BaseModel):
|
||||
status: str
|
||||
note: str | None = None
|
||||
created_at: NaiveDatetimeOptional = None
|
||||
handled_at: NaiveDatetimeOptional = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.enums.upload_resource import (
|
||||
UploadResourceBindStatusEnum,
|
||||
UploadResourceDeletePolicyEnum,
|
||||
UploadResourceModuleEnum,
|
||||
UploadResourceTypeEnum,
|
||||
)
|
||||
from app.schemas.common import NaiveDatetimeOptional
|
||||
|
||||
|
||||
class UploadResourceMediaReferenceOut(BaseModel):
|
||||
"""AI 创作可直接复用的上传素材参考结构。"""
|
||||
|
||||
name: str | None = Field(None, description="参考素材名称,前端展示用")
|
||||
type: Literal["image", "video", "audio"] = Field(..., description="参考素材类型:image=图片,video=视频,audio=音频")
|
||||
url: str = Field(..., description="参考素材 URL,创建 AI 创作任务时传入 media_references[].url")
|
||||
label: str | None = Field("", description="前端展示标签,默认空,由 AI 创作页重新计算")
|
||||
duration: float | None = Field(None, ge=0, description="素材时长秒数,type=video/audio 时必须有有效值")
|
||||
source: Literal["upload_resource"] = Field("upload_resource", description="参考素材来源固定为 upload_resource")
|
||||
upload_resource_id: str = Field(..., description="UploadResource 本地资源账本 ID")
|
||||
|
||||
|
||||
class UploadResourceHistoryItemOut(BaseModel):
|
||||
"""上传资源历史素材项。"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": "0019e0a448a23114888",
|
||||
"source_type": "upload_resource",
|
||||
"history_source": "upload_resource",
|
||||
"history_source_label": "历史上传素材",
|
||||
"module": "common",
|
||||
"module_label": "普通上传",
|
||||
"resource_type": "video",
|
||||
"resource_type_label": "视频",
|
||||
"resource_url": "/uploads/videos/2026/07/08/video_ref_xxx.mp4",
|
||||
"display_url": "/uploads/videos/2026/07/08/video_ref_xxx.mp4",
|
||||
"preview_url": "/uploads/videos/2026/07/08/video_ref_xxx.mp4",
|
||||
"video_url": "/uploads/videos/2026/07/08/video_ref_xxx.mp4",
|
||||
"duration_seconds": 5.2,
|
||||
"bind_status": "pending",
|
||||
"delete_policy": "user_deletable",
|
||||
"deletable": True,
|
||||
"media_reference": {
|
||||
"name": "video_ref_xxx.mp4",
|
||||
"type": "video",
|
||||
"url": "/uploads/videos/2026/07/08/video_ref_xxx.mp4",
|
||||
"duration": 5.2,
|
||||
"source": "upload_resource",
|
||||
"upload_resource_id": "0019e0a448a23114888",
|
||||
},
|
||||
"created_at": "2026-07-08T13:20:00",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
id: str = Field(..., description="UploadResource.id")
|
||||
source_type: Literal["upload_resource"] = Field("upload_resource", description="前端判定来源固定值")
|
||||
history_source: Literal["upload_resource"] = Field("upload_resource", description="素材云历史来源固定为 upload_resource")
|
||||
history_source_label: str = Field("历史上传素材", description="素材云历史来源中文名称")
|
||||
|
||||
module: str = Field(..., description="上传资源所属模块:common=普通上传,hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻")
|
||||
module_label: str = Field(..., description="上传资源所属模块中文名称")
|
||||
resource_type: Literal["image", "video", "audio"] = Field(..., description="资源类型:image=图片,video=视频,audio=音频")
|
||||
resource_type_label: str = Field(..., description="资源类型中文名称")
|
||||
|
||||
resource_url: str = Field(..., description="原始资源 URL,可传给 AI 创作 media_references[].url")
|
||||
display_url: str = Field(..., description="前端展示 URL。当前普通上传资源通常与 resource_url 一致")
|
||||
preview_url: str = Field(..., description="前端预览 URL。当前普通上传资源通常与 display_url 一致")
|
||||
image_url: str | None = Field(None, description="图片资源 URL;resource_type=image 时有值")
|
||||
video_url: str | None = Field(None, description="视频资源 URL;resource_type=video 时有值")
|
||||
audio_url: str | None = Field(None, description="音频资源 URL;resource_type=audio 时有值")
|
||||
|
||||
file_name: str | None = Field(None, description="文件名")
|
||||
file_ext: str | None = Field(None, description="文件扩展名")
|
||||
mime_type: str | None = Field(None, description="MIME 类型")
|
||||
file_size_bytes: int = Field(0, description="文件大小,单位字节")
|
||||
duration_seconds: float | None = Field(None, description="视频/音频时长秒数")
|
||||
width: int | None = Field(None, description="图片或视频宽度,未识别时为空")
|
||||
height: int | None = Field(None, description="图片或视频高度,未识别时为空")
|
||||
|
||||
bind_status: str = Field(..., description="绑定状态。历史素材接口只返回 pending=未绑定")
|
||||
delete_policy: str = Field(..., description="删除策略。历史素材接口只返回 user_deletable=用户可删除")
|
||||
deletable: bool = Field(True, description="是否允许当前用户删除。历史素材接口只返回 true")
|
||||
media_reference: UploadResourceMediaReferenceOut = Field(..., description="AI 创作可直接复用的参考素材对象")
|
||||
|
||||
created_at: NaiveDatetimeOptional = Field(None, description="上传时间")
|
||||
updated_at: NaiveDatetimeOptional = Field(None, description="更新时间")
|
||||
|
||||
|
||||
class UploadResourceHistoryDayGroupOut(BaseModel):
|
||||
"""上传资源历史按天分组响应项。"""
|
||||
|
||||
generated_date: str = Field(..., description="上传日期,格式 YYYY-MM-DD。字段名沿用生成历史,方便前端复用分组逻辑")
|
||||
total: int = Field(..., description="当前日期下可展示/可复用/可删除的上传素材总数")
|
||||
page: int = Field(1, description="当前日期下 items 的页码,分组接口固定为 1")
|
||||
items: list[UploadResourceHistoryItemOut] = Field(default_factory=list, description="当前日期倒序前若干条上传素材")
|
||||
|
||||
|
||||
class UploadResourceHistoryGroupedOut(BaseModel):
|
||||
"""上传资源历史日期分组列表响应体。"""
|
||||
|
||||
total_days: int = Field(..., description="符合条件的上传素材日期分组总数")
|
||||
page: int = Field(..., description="日期分组页码,从 1 开始")
|
||||
page_size: int = Field(..., description="日期分组每页数量,最大 10")
|
||||
groups: list[UploadResourceHistoryDayGroupOut] = Field(default_factory=list, description="上传日期倒序分组列表")
|
||||
|
||||
|
||||
class UploadResourceHistoryDayItemsOut(BaseModel):
|
||||
"""指定日期下上传资源历史分页响应体。"""
|
||||
|
||||
generated_date: str = Field(..., description="当前查询上传日期,格式 YYYY-MM-DD")
|
||||
total: int = Field(..., description="当前日期下符合条件的上传素材总数")
|
||||
page: int = Field(..., description="当前日期下分页页码,从 1 开始")
|
||||
page_size: int = Field(..., description="当前日期下每页返回数量,最大 100")
|
||||
items: list[UploadResourceHistoryItemOut] = Field(default_factory=list, description="当前日期下上传素材列表,按上传时间倒序排列")
|
||||
|
||||
|
||||
class UploadResourceHistoryBatchDeleteRequest(BaseModel):
|
||||
"""批量删除上传历史素材请求体。"""
|
||||
|
||||
resource_ids: list[str] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=30,
|
||||
description="UploadResource.id 列表。一次最多 30 条,不允许重复",
|
||||
examples=[["0019e0a448a23114888", "0019e0a448a23114889"]],
|
||||
)
|
||||
|
||||
@field_validator("resource_ids")
|
||||
@classmethod
|
||||
def validate_resource_ids(cls, values: list[str]) -> list[str]:
|
||||
cleaned = [str(value).strip() for value in values if str(value or "").strip()]
|
||||
if not cleaned:
|
||||
raise ValueError("resource_ids 不能为空")
|
||||
if len(cleaned) > 30:
|
||||
raise ValueError("单次最多删除 30 条上传素材")
|
||||
if len(cleaned) != len(set(cleaned)):
|
||||
raise ValueError("resource_ids 不允许重复")
|
||||
return cleaned
|
||||
|
||||
|
||||
class UploadResourceCleanupOut(BaseModel):
|
||||
"""真实文件清理结果。"""
|
||||
|
||||
matched: int = Field(0, description="匹配到待清理资源数量")
|
||||
deleted: int = Field(0, description="真实文件删除成功数量")
|
||||
missing: int = Field(0, description="真实文件已不存在数量")
|
||||
failed: int = Field(0, description="真实文件删除失败数量")
|
||||
legacy_deleted: int = Field(0, description="兼容历史路径删除成功数量")
|
||||
legacy_missing: int = Field(0, description="兼容历史路径不存在数量")
|
||||
legacy_failed: int = Field(0, description="兼容历史路径删除失败数量")
|
||||
|
||||
|
||||
class UploadResourceHistoryBatchDeleteOut(BaseModel):
|
||||
"""批量删除上传历史素材响应体。"""
|
||||
|
||||
message: str = Field("删除成功", description="操作结果消息")
|
||||
requested_count: int = Field(..., description="请求删除数量")
|
||||
deleted_count: int = Field(..., description="成功软删数量")
|
||||
requested_ids: list[str] = Field(default_factory=list, description="请求删除的 UploadResource.id 列表")
|
||||
deleted_ids: list[str] = Field(default_factory=list, description="已软删的 UploadResource.id 列表")
|
||||
released_size_bytes: int = Field(0, description="已释放的上传容量字节数")
|
||||
cleanup: UploadResourceCleanupOut = Field(default_factory=UploadResourceCleanupOut, description="commit 成功后的真实文件清理结果")
|
||||
|
||||
|
||||
UPLOAD_RESOURCE_HISTORY_ALLOWED_RESOURCE_TYPES = {
|
||||
UploadResourceTypeEnum.IMAGE.value,
|
||||
UploadResourceTypeEnum.VIDEO.value,
|
||||
UploadResourceTypeEnum.AUDIO.value,
|
||||
}
|
||||
|
||||
UPLOAD_RESOURCE_HISTORY_ALLOWED_BIND_STATUS = UploadResourceBindStatusEnum.PENDING.value
|
||||
UPLOAD_RESOURCE_HISTORY_ALLOWED_DELETE_POLICY = UploadResourceDeletePolicyEnum.USER_DELETABLE.value
|
||||
UPLOAD_RESOURCE_HISTORY_ALLOWED_MODULES = {
|
||||
UploadResourceModuleEnum.COMMON.value,
|
||||
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
|
||||
UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
}
|
||||
@@ -83,6 +83,8 @@ from app.services.module_generation_step_update_service import (
|
||||
update_module_video_prompt_schema,
|
||||
)
|
||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum
|
||||
from app.services.upload_resource import release_upload_resources_by_source
|
||||
from app.services.video_prompt_schema_config_service import fallback_runtime_schema_snapshot, get_runtime_schema_snapshot
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
@@ -501,6 +503,9 @@ async def create_hot_opening_project(db: AsyncSession, current_user: User, req:
|
||||
input_data={
|
||||
"material_video_url": req.material_video_url,
|
||||
"material_image_url": req.material_image_url,
|
||||
"material_video_resource_id": req.material_video_resource_id,
|
||||
"material_image_resource_id": req.material_image_resource_id,
|
||||
"material_video_duration_seconds": req.material_video_duration_seconds,
|
||||
"source_project_name": req.source_project_name,
|
||||
"target_project_name": req.target_project_name,
|
||||
"core_content_point": req.core_content_point,
|
||||
@@ -1183,6 +1188,10 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
|
||||
video_config=video_config,
|
||||
target_platform=target_platform,
|
||||
schema_config_snapshot=schema_config_snapshot,
|
||||
module=project.module,
|
||||
project_id=project.id,
|
||||
step_id=step.id,
|
||||
trace_id=f"hot-video-prompt:{step.id}",
|
||||
)
|
||||
billing = await charge_module_prompt_usage(
|
||||
db,
|
||||
@@ -1502,11 +1511,37 @@ async def mark_hot_opening_step_dispatch_failed(
|
||||
|
||||
async def delete_hot_opening_project(db: AsyncSession, *, current_user: User, project_id: str) -> HotOpeningDeleteOut:
|
||||
project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True)
|
||||
project_id_snapshot = project.id
|
||||
deleted_at = _now()
|
||||
project.deleted_at = deleted_at
|
||||
await _soft_delete_steps_from_index(db, project=project, start_index=1, deleted_at=deleted_at)
|
||||
await log_module_event(db, project=project, event_type=ModuleEventTypeEnum.PROJECT_DELETED.value, message="软删除爆款开头复刻项目")
|
||||
return HotOpeningDeleteOut(message="项目已删除", project_id=project.id, deleted=True)
|
||||
upload_release = await release_upload_resources_by_source(
|
||||
db,
|
||||
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
|
||||
source_ids=[project_id_snapshot],
|
||||
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
|
||||
)
|
||||
pending_ids = list(upload_release.get("released_resource_ids") or [])
|
||||
released_size = int(upload_release.get("released_size_bytes") or 0)
|
||||
upload_resource_released = int(upload_release.get("released") or 0)
|
||||
await log_module_event(
|
||||
db,
|
||||
project=project,
|
||||
event_type=ModuleEventTypeEnum.PROJECT_DELETED.value,
|
||||
message="软删除爆款开头复刻项目",
|
||||
detail={
|
||||
"upload_resource_release": {k: v for k, v in upload_release.items() if k != "released_resource_ids"},
|
||||
"pending_delete_resource_count": len(pending_ids),
|
||||
},
|
||||
)
|
||||
return HotOpeningDeleteOut(
|
||||
message="项目已删除",
|
||||
project_id=project_id_snapshot,
|
||||
deleted=True,
|
||||
released_size_bytes=released_size,
|
||||
upload_resource_released=upload_resource_released,
|
||||
pending_delete_resource_ids=pending_ids,
|
||||
)
|
||||
|
||||
def _build_file_url_or_data_uri(file_url: str) -> str:
|
||||
return _common_build_file_url_or_data_uri(file_url)
|
||||
|
||||
@@ -10,6 +10,10 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.common import LogEventStatusEnum, LogSourceEnum
|
||||
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningRemoteActionEnum, ModuleCodeEnum as HotModuleCodeEnum
|
||||
from app.enums.shot_replicate import ModuleCodeEnum as ShotModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum
|
||||
from app.services.operation_log_service import log_ai_model_event
|
||||
from app.enums.common import (
|
||||
VIDEO_SCHEMA_CONFIG_DATABASE_SOURCE,
|
||||
VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE,
|
||||
@@ -1415,6 +1419,100 @@ def _mock_result(video_config: dict[str, Any], target_platform: str) -> dict[str
|
||||
return schema
|
||||
|
||||
|
||||
|
||||
|
||||
def _safe_response_json(response: httpx.Response | None) -> Any:
|
||||
if response is None:
|
||||
return None
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return {"raw_text": response.text}
|
||||
|
||||
|
||||
def _extract_remote_request_id(data: Any) -> str | None:
|
||||
if isinstance(data, dict):
|
||||
error = data.get("error") if isinstance(data.get("error"), dict) else {}
|
||||
request_id = data.get("request_id") or error.get("request_id")
|
||||
message = error.get("message") or data.get("message")
|
||||
if not request_id and isinstance(message, str):
|
||||
import re
|
||||
match = re.search(r"Request id:\s*([a-zA-Z0-9_.:-]+)", message, flags=re.IGNORECASE)
|
||||
if match:
|
||||
request_id = match.group(1)
|
||||
return str(request_id) if request_id else None
|
||||
return None
|
||||
|
||||
|
||||
def _video_prompt_remote_event(module: str, *, started: bool = False, success: bool = False, parse_failed: bool = False, empty: bool = False) -> tuple[str, str]:
|
||||
if module == ShotModuleCodeEnum.SHOT_REPLICATE.value:
|
||||
action = ShotReplicateRemoteActionEnum.VIDEO_PROMPT_OPTIMIZE.value
|
||||
if started:
|
||||
return ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_STARTED.value, action
|
||||
if success:
|
||||
return ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_SUCCESS.value, action
|
||||
if parse_failed:
|
||||
return ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value, action
|
||||
if empty:
|
||||
return ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_EMPTY.value, action
|
||||
return ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_FAILED.value, action
|
||||
action = HotOpeningRemoteActionEnum.VIDEO_PROMPT_OPTIMIZE.value
|
||||
if started:
|
||||
return HotOpeningLogEventEnum.VIDEO_PROMPT_REMOTE_API_STARTED.value, action
|
||||
if success:
|
||||
return HotOpeningLogEventEnum.VIDEO_PROMPT_REMOTE_API_SUCCESS.value, action
|
||||
if parse_failed:
|
||||
return HotOpeningLogEventEnum.VIDEO_PROMPT_RESPONSE_PARSE_FAILED.value, action
|
||||
if empty:
|
||||
return HotOpeningLogEventEnum.VIDEO_PROMPT_RESPONSE_EMPTY.value, action
|
||||
return HotOpeningLogEventEnum.VIDEO_PROMPT_REMOTE_API_FAILED.value, action
|
||||
|
||||
|
||||
def _log_video_prompt_ai_event(
|
||||
*,
|
||||
module: str,
|
||||
event_type: str,
|
||||
action: str,
|
||||
event_status: str,
|
||||
config: ModelConfig,
|
||||
trace_id: str | None,
|
||||
user_id: str | None,
|
||||
project_id: str | None,
|
||||
step_id: str | None,
|
||||
request_data: dict[str, Any] | None = None,
|
||||
response_data: Any = None,
|
||||
token_usage: dict[str, Any] | None = None,
|
||||
http_status: int | None = None,
|
||||
remote_request_id: str | None = None,
|
||||
message: str | None = None,
|
||||
error: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
log_ai_model_event(
|
||||
event_type=event_type,
|
||||
event_status=event_status,
|
||||
source=LogSourceEnum.REMOTE_API.value,
|
||||
module=module,
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
remote_action=action,
|
||||
remote_request_id=remote_request_id,
|
||||
model_config_id=str(config.id),
|
||||
model_config_name=config.name,
|
||||
model_name=config.model_name,
|
||||
provider=config.provider,
|
||||
api_base=config.api_base,
|
||||
http_status=http_status,
|
||||
request=request_data,
|
||||
response=response_data,
|
||||
token_usage=token_usage,
|
||||
message=message,
|
||||
detail=detail,
|
||||
error=error,
|
||||
)
|
||||
|
||||
async def _select_model_config(db: AsyncSession) -> ModelConfig | None:
|
||||
result = await db.execute(select(ModelConfig).where(ModelConfig.is_active == True).order_by(ModelConfig.priority.desc()).limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
@@ -1432,6 +1530,10 @@ async def optimize_hot_opening_video_prompt(
|
||||
video_config: dict[str, Any],
|
||||
target_platform: str = "抖音",
|
||||
schema_config_snapshot: Any | None = None,
|
||||
module: str = HotModuleCodeEnum.HOT_OPENING_REPLICATE.value,
|
||||
project_id: str | None = None,
|
||||
step_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
||||
duration = int(video_config["duration"])
|
||||
references = [
|
||||
@@ -1469,18 +1571,109 @@ async def optimize_hot_opening_video_prompt(
|
||||
"temperature": 0.15,
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
api_url = f"{config.api_base.rstrip('/')}/chat/completions"
|
||||
log_request_data = {
|
||||
**request_data,
|
||||
"messages": [{"role": "system", "content": build_system_prompt()}, log_user_message],
|
||||
"model_config_id": config.id,
|
||||
"model_config_name": config.name,
|
||||
"provider": config.provider,
|
||||
"module": module,
|
||||
"project_id": project_id,
|
||||
"step_id": step_id,
|
||||
"material_video_url": material_video_url,
|
||||
"generated_image_url": generated_image_url,
|
||||
"target_platform": target_platform,
|
||||
"api_url": api_url,
|
||||
}
|
||||
started_event, remote_action = _video_prompt_remote_event(module, started=True)
|
||||
_log_video_prompt_ai_event(
|
||||
module=module,
|
||||
event_type=started_event,
|
||||
action=remote_action,
|
||||
event_status=LogEventStatusEnum.STARTED.value,
|
||||
config=config,
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
request_data=log_request_data,
|
||||
message="视频提词模型请求开始",
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=int(settings.CHATAPI_REQUEST_TIMEOUT_SECONDS or 180)) as client:
|
||||
response = await client.post(
|
||||
f"{config.api_base.rstrip('/')}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"},
|
||||
json=request_data,
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=int(settings.CHATAPI_REQUEST_TIMEOUT_SECONDS or 180)) as client:
|
||||
response = await client.post(
|
||||
api_url,
|
||||
headers={"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"},
|
||||
json=request_data,
|
||||
)
|
||||
except Exception as exc:
|
||||
failed_event, remote_action = _video_prompt_remote_event(module)
|
||||
_log_video_prompt_ai_event(
|
||||
module=module,
|
||||
event_type=failed_event,
|
||||
action=remote_action,
|
||||
event_status=LogEventStatusEnum.FAILED.value,
|
||||
config=config,
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
request_data=log_request_data,
|
||||
message="视频提词模型请求异常",
|
||||
error=str(exc),
|
||||
detail={"exception_type": type(exc).__name__},
|
||||
)
|
||||
raise
|
||||
response_data = _safe_response_json(response)
|
||||
remote_request_id = _extract_remote_request_id(response_data)
|
||||
if response.status_code >= 400:
|
||||
failed_event, remote_action = _video_prompt_remote_event(module)
|
||||
_log_video_prompt_ai_event(
|
||||
module=module,
|
||||
event_type=failed_event,
|
||||
action=remote_action,
|
||||
event_status=LogEventStatusEnum.FAILED.value,
|
||||
config=config,
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
request_data=log_request_data,
|
||||
response_data=response_data,
|
||||
http_status=response.status_code,
|
||||
remote_request_id=remote_request_id,
|
||||
message="视频提词模型请求失败",
|
||||
error=f"HTTP {response.status_code}: {response.text}",
|
||||
)
|
||||
raise RuntimeError(f"视频提词优化失败 HTTP {response.status_code}: {response.text}")
|
||||
|
||||
data = response.json()
|
||||
content = data["choices"][0]["message"]["content"].strip()
|
||||
try:
|
||||
data = response.json()
|
||||
content = data["choices"][0]["message"]["content"].strip()
|
||||
if not content:
|
||||
raise RuntimeError("视频提词模型响应 content 为空")
|
||||
except Exception as exc:
|
||||
parse_event, remote_action = _video_prompt_remote_event(module, empty="content 为空" in str(exc), parse_failed="content 为空" not in str(exc))
|
||||
_log_video_prompt_ai_event(
|
||||
module=module,
|
||||
event_type=parse_event,
|
||||
action=remote_action,
|
||||
event_status=LogEventStatusEnum.FAILED.value,
|
||||
config=config,
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
request_data=log_request_data,
|
||||
response_data=response_data,
|
||||
http_status=response.status_code,
|
||||
remote_request_id=remote_request_id,
|
||||
message="视频提词模型响应解析失败",
|
||||
error=str(exc),
|
||||
)
|
||||
raise
|
||||
usage = data.get("usage", {}) or {}
|
||||
token_usage = {
|
||||
"input_tokens": int(usage.get("prompt_tokens") or 0),
|
||||
@@ -1508,8 +1701,47 @@ async def optimize_hot_opening_video_prompt(
|
||||
"model_name": config.model_name,
|
||||
})
|
||||
|
||||
result = parse_model_json(content)
|
||||
result = normalize_video_prompt_schema_from_ai(result, video_config, schema_config_snapshot)
|
||||
try:
|
||||
result = parse_model_json(content)
|
||||
result = normalize_video_prompt_schema_from_ai(result, video_config, schema_config_snapshot)
|
||||
except Exception as exc:
|
||||
parse_event, remote_action = _video_prompt_remote_event(module, parse_failed=True)
|
||||
_log_video_prompt_ai_event(
|
||||
module=module,
|
||||
event_type=parse_event,
|
||||
action=remote_action,
|
||||
event_status=LogEventStatusEnum.FAILED.value,
|
||||
config=config,
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
request_data=log_request_data,
|
||||
response_data=data,
|
||||
http_status=response.status_code,
|
||||
remote_request_id=remote_request_id,
|
||||
message="视频提词业务 JSON 解析失败",
|
||||
error=str(exc),
|
||||
)
|
||||
raise
|
||||
success_event, remote_action = _video_prompt_remote_event(module, success=True)
|
||||
_log_video_prompt_ai_event(
|
||||
module=module,
|
||||
event_type=success_event,
|
||||
action=remote_action,
|
||||
event_status=LogEventStatusEnum.SUCCESS.value,
|
||||
config=config,
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
request_data=log_request_data,
|
||||
response_data=data,
|
||||
token_usage=token_usage,
|
||||
http_status=response.status_code,
|
||||
remote_request_id=remote_request_id,
|
||||
message="视频提词模型请求成功",
|
||||
)
|
||||
return result, build_final_video_prompt(result), token_usage
|
||||
|
||||
def _build_file_url_or_data_uri(file_url: str) -> str:
|
||||
|
||||
@@ -98,6 +98,11 @@ def _lease_seconds() -> int:
|
||||
return max(1, int(settings.MODULE_ASYNC_LEASE_SECONDS or 600))
|
||||
|
||||
|
||||
def _shot_analysis_lease_seconds() -> int:
|
||||
timeout = max(1, int(getattr(settings, "SHOT_ANALYSIS_TIMEOUT_SECONDS", 600) or 600))
|
||||
return max(_lease_seconds(), timeout + 120)
|
||||
|
||||
|
||||
def _queue_timeout_seconds() -> int:
|
||||
return max(1, int(settings.MODULE_ASYNC_QUEUE_TIMEOUT_SECONDS or 300))
|
||||
|
||||
@@ -227,7 +232,7 @@ async def register_shot_task_set_analysis_task(task_set_id: str) -> str:
|
||||
module=SHOT_MODULE,
|
||||
project_id=task_set_id,
|
||||
task_set_id=task_set_id,
|
||||
check_after_seconds=_lease_seconds(),
|
||||
check_after_seconds=_shot_analysis_lease_seconds(),
|
||||
)
|
||||
|
||||
|
||||
@@ -242,7 +247,7 @@ async def register_shot_segment_analysis_task(segment_id: str, *, task_set_id: s
|
||||
project_id=task_set_id,
|
||||
task_set_id=task_set_id,
|
||||
segment_id=segment_id,
|
||||
check_after_seconds=_lease_seconds(),
|
||||
check_after_seconds=_shot_analysis_lease_seconds(),
|
||||
)
|
||||
|
||||
|
||||
@@ -297,10 +302,15 @@ async def postpone_active_task(
|
||||
|
||||
|
||||
async def mark_active_started(*, object_type: str, object_id: str, reason: str = "started") -> None:
|
||||
delay_seconds = _lease_seconds()
|
||||
if object_type in {OBJECT_SHOT_TASK_SET_ANALYSIS, OBJECT_SHOT_SEGMENT_ANALYSIS}:
|
||||
delay_seconds = _shot_analysis_lease_seconds()
|
||||
elif object_type == OBJECT_SHOT_SPLIT_SEGMENT:
|
||||
delay_seconds = max(_lease_seconds(), int(settings.SHOT_SPLIT_LEASE_SECONDS or 600))
|
||||
await postpone_active_task(
|
||||
object_type=object_type,
|
||||
object_id=object_id,
|
||||
delay_seconds=_lease_seconds(),
|
||||
delay_seconds=delay_seconds,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.services.operation_log_service import build_exception_detail, log_operation_error, log_operation_event
|
||||
from app.enums.common import LogEventStatusEnum, LogSourceEnum
|
||||
from app.services.operation_log_service import (
|
||||
build_exception_detail,
|
||||
log_module_generation_event,
|
||||
)
|
||||
|
||||
|
||||
def log_module_event_file(
|
||||
@@ -11,23 +15,28 @@ def log_module_event_file(
|
||||
event_type: str,
|
||||
project_id: str | None = None,
|
||||
step_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
source: str | None = None,
|
||||
event_status: str | None = None,
|
||||
message: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
log_operation_event(
|
||||
domain="module_generation",
|
||||
log_module_generation_event(
|
||||
module=module,
|
||||
event_type=event_type,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
task_id=task_id,
|
||||
user_id=user_id,
|
||||
trace_id=trace_id,
|
||||
message=message,
|
||||
detail=detail,
|
||||
error=error,
|
||||
event_status="failed" if error else "success",
|
||||
source="service",
|
||||
event_status=event_status or (LogEventStatusEnum.FAILED.value if error else LogEventStatusEnum.SUCCESS.value),
|
||||
source=source or LogSourceEnum.SERVICE.value,
|
||||
)
|
||||
|
||||
|
||||
@@ -39,23 +48,24 @@ def log_module_prompt_event(
|
||||
user_id: str,
|
||||
module: str,
|
||||
prompt_type: str,
|
||||
trace_id: str | None = None,
|
||||
request: dict[str, Any] | None = None,
|
||||
response: dict[str, Any] | None = None,
|
||||
token_usage: dict[str, Any] | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
log_operation_event(
|
||||
domain="module_generation",
|
||||
log_module_generation_event(
|
||||
module=module,
|
||||
event_type=event_type,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
user_id=user_id,
|
||||
trace_id=trace_id,
|
||||
message=f"模块 AI 请求:{prompt_type}",
|
||||
detail={"prompt_type": prompt_type, "request": request or {}, "response": response or {}, "token_usage": token_usage or {}},
|
||||
error=error,
|
||||
event_status="failed" if error else "success",
|
||||
source="service",
|
||||
event_status=LogEventStatusEnum.FAILED.value if error else LogEventStatusEnum.SUCCESS.value,
|
||||
source=LogSourceEnum.SERVICE.value,
|
||||
)
|
||||
|
||||
|
||||
@@ -65,22 +75,26 @@ def log_module_error(
|
||||
event_type: str,
|
||||
project_id: str | None = None,
|
||||
step_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
source: str | None = None,
|
||||
message: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
error: str | None = None,
|
||||
exc: BaseException | None = None,
|
||||
) -> None:
|
||||
log_operation_error(
|
||||
domain="module_generation",
|
||||
log_module_generation_event(
|
||||
module=module,
|
||||
event_type=event_type,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
task_id=task_id,
|
||||
user_id=user_id,
|
||||
trace_id=trace_id,
|
||||
message=message,
|
||||
detail=detail,
|
||||
detail=build_exception_detail(exc, detail),
|
||||
error=error if error is not None else (str(exc) if exc else None),
|
||||
exc=exc,
|
||||
source="service",
|
||||
event_status=LogEventStatusEnum.FAILED.value,
|
||||
source=source or LogSourceEnum.SERVICE.value,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
@@ -10,9 +11,14 @@ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from app.services.log_config import LOG_DATE_FORMAT, LOG_DIR, is_enabled
|
||||
|
||||
logger = logging.getLogger("video_gen")
|
||||
|
||||
MAX_LOG_FIELD_LENGTH = 20000
|
||||
MAX_TRACEBACK_LENGTH = 12000
|
||||
OPERATION_LOG_ROOT = os.path.join(os.path.dirname(LOG_DIR), "OperationLogs")
|
||||
LOG_BASE_DIR = os.path.dirname(LOG_DIR)
|
||||
OPERATION_LOG_ROOT = os.path.join(LOG_BASE_DIR, "OperationLogs")
|
||||
MODULE_GENERATION_LOG_ROOT = os.path.join(LOG_BASE_DIR, "ModuleGeneration")
|
||||
AI_MODEL_LOG_ROOT = LOG_DIR
|
||||
SENSITIVE_KEY_PATTERNS = (
|
||||
"secret",
|
||||
"token",
|
||||
@@ -94,17 +100,69 @@ def build_exception_detail(exc: BaseException | None, extra: dict[str, Any] | No
|
||||
return detail
|
||||
|
||||
|
||||
def _append_operation_log(domain: str, entry: dict[str, Any]) -> None:
|
||||
def _append_json_log(root_dir: str, domain: str | None, entry: dict[str, Any]) -> None:
|
||||
if not is_enabled():
|
||||
return
|
||||
try:
|
||||
domain_dir = os.path.join(OPERATION_LOG_ROOT, _safe_name(domain, "default"))
|
||||
os.makedirs(domain_dir, exist_ok=True)
|
||||
target_dir = root_dir if domain is None else os.path.join(root_dir, _safe_name(domain, "default"))
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
||||
with open(os.path.join(domain_dir, f"{today}.log"), "a", encoding="utf-8") as f:
|
||||
with open(os.path.join(target_dir, f"{today}.log"), "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(sanitize_log_value(entry), ensure_ascii=False, default=str) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning("operation log write failed: root_dir=%s domain=%s error=%s", root_dir, domain, exc, exc_info=True)
|
||||
|
||||
|
||||
def _append_operation_log(domain: str, entry: dict[str, Any]) -> None:
|
||||
_append_json_log(OPERATION_LOG_ROOT, domain, entry)
|
||||
|
||||
|
||||
def _base_entry(
|
||||
*,
|
||||
log_type: str,
|
||||
domain: str,
|
||||
event_type: str,
|
||||
module: str | None = None,
|
||||
event_status: str = "success",
|
||||
source: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
request_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
group_id: str | None = None,
|
||||
asset_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
step_id: str | None = None,
|
||||
remote_action: str | None = None,
|
||||
remote_request_id: str | None = None,
|
||||
message: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
error: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"log_type": log_type,
|
||||
"domain": domain,
|
||||
"module": module or domain,
|
||||
"event_type": event_type,
|
||||
"event_status": event_status,
|
||||
"source": source,
|
||||
"trace_id": trace_id,
|
||||
"request_id": request_id,
|
||||
"user_id": user_id,
|
||||
"project_id": project_id,
|
||||
"session_id": session_id,
|
||||
"group_id": group_id,
|
||||
"asset_id": asset_id,
|
||||
"task_id": task_id,
|
||||
"step_id": step_id,
|
||||
"remote_action": remote_action,
|
||||
"remote_request_id": remote_request_id,
|
||||
"message": message,
|
||||
"detail": detail or {},
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def log_operation_event(
|
||||
@@ -131,32 +189,142 @@ def log_operation_event(
|
||||
) -> None:
|
||||
_append_operation_log(
|
||||
domain,
|
||||
{
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"log_type": "operation_event",
|
||||
"domain": domain,
|
||||
"module": module or domain,
|
||||
"event_type": event_type,
|
||||
"event_status": event_status,
|
||||
"source": source,
|
||||
"trace_id": trace_id,
|
||||
"request_id": request_id,
|
||||
"user_id": user_id,
|
||||
"project_id": project_id,
|
||||
"session_id": session_id,
|
||||
"group_id": group_id,
|
||||
"asset_id": asset_id,
|
||||
"task_id": task_id,
|
||||
"step_id": step_id,
|
||||
"remote_action": remote_action,
|
||||
"remote_request_id": remote_request_id,
|
||||
"message": message,
|
||||
"detail": detail or {},
|
||||
"error": error,
|
||||
},
|
||||
_base_entry(
|
||||
log_type="operation_event",
|
||||
domain=domain,
|
||||
module=module,
|
||||
event_type=event_type,
|
||||
event_status=event_status,
|
||||
source=source,
|
||||
trace_id=trace_id,
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
session_id=session_id,
|
||||
group_id=group_id,
|
||||
asset_id=asset_id,
|
||||
task_id=task_id,
|
||||
step_id=step_id,
|
||||
remote_action=remote_action,
|
||||
remote_request_id=remote_request_id,
|
||||
message=message,
|
||||
detail=detail,
|
||||
error=error,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def log_module_generation_event(
|
||||
*,
|
||||
module: str,
|
||||
event_type: str,
|
||||
event_status: str = "success",
|
||||
source: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
request_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
step_id: str | None = None,
|
||||
remote_action: str | None = None,
|
||||
remote_request_id: str | None = None,
|
||||
message: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
"""Write module business logs to log/ModuleGeneration/{module}/YYYY-MM-DD.log."""
|
||||
_append_json_log(
|
||||
MODULE_GENERATION_LOG_ROOT,
|
||||
module,
|
||||
_base_entry(
|
||||
log_type="module_generation_event",
|
||||
domain="module_generation",
|
||||
module=module,
|
||||
event_type=event_type,
|
||||
event_status=event_status,
|
||||
source=source,
|
||||
trace_id=trace_id,
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
task_id=task_id,
|
||||
step_id=step_id,
|
||||
remote_action=remote_action,
|
||||
remote_request_id=remote_request_id,
|
||||
message=message,
|
||||
detail=detail,
|
||||
error=error,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def log_ai_model_event(
|
||||
*,
|
||||
event_type: str,
|
||||
module: str | None = None,
|
||||
event_status: str = "success",
|
||||
source: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
request_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
step_id: str | None = None,
|
||||
remote_action: str | None = None,
|
||||
remote_request_id: str | None = None,
|
||||
model_config_id: str | None = None,
|
||||
model_config_name: str | None = None,
|
||||
model_name: str | None = None,
|
||||
provider: str | None = None,
|
||||
api_base: str | None = None,
|
||||
http_status: int | None = None,
|
||||
request: dict[str, Any] | None = None,
|
||||
response: dict[str, Any] | list[Any] | str | None = None,
|
||||
token_usage: dict[str, Any] | None = None,
|
||||
message: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
"""Write AI model call logs to existing log/AiModel/YYYY-MM-DD.log."""
|
||||
final_detail = dict(detail or {})
|
||||
if request is not None:
|
||||
final_detail["request"] = request
|
||||
if response is not None:
|
||||
final_detail["response"] = response
|
||||
if token_usage is not None:
|
||||
final_detail["token_usage"] = token_usage
|
||||
entry = _base_entry(
|
||||
log_type="ai_model_event",
|
||||
domain="ai_model",
|
||||
module=module or "ai_model",
|
||||
event_type=event_type,
|
||||
event_status=event_status,
|
||||
source=source,
|
||||
trace_id=trace_id,
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
task_id=task_id,
|
||||
step_id=step_id,
|
||||
remote_action=remote_action,
|
||||
remote_request_id=remote_request_id,
|
||||
message=message,
|
||||
detail=final_detail,
|
||||
error=error,
|
||||
)
|
||||
entry.update(
|
||||
{
|
||||
"model_name": model_config_name,
|
||||
"model_id": model_name,
|
||||
"model_config_id": model_config_id,
|
||||
"provider": provider,
|
||||
"api_base": api_base,
|
||||
"http_status": http_status,
|
||||
}
|
||||
)
|
||||
_append_json_log(AI_MODEL_LOG_ROOT, None, entry)
|
||||
|
||||
|
||||
def log_operation_error(*, domain: str, event_type: str, exc: BaseException | None = None, detail: dict[str, Any] | None = None, **kwargs: Any) -> None:
|
||||
kwargs.setdefault("event_status", "failed")
|
||||
kwargs["detail"] = build_exception_detail(exc, detail)
|
||||
|
||||
@@ -88,6 +88,8 @@ from app.services.module_generation_step_update_service import (
|
||||
update_module_video_prompt_schema,
|
||||
)
|
||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum
|
||||
from app.services.upload_resource import bind_upload_resources, release_upload_resources_by_source
|
||||
from app.services.video_prompt_schema_config_service import fallback_runtime_schema_snapshot, get_runtime_schema_snapshot
|
||||
from app.utils.id_gen import generate_id
|
||||
from app.models.shot_replicate_segment import ShotReplicateSegment
|
||||
@@ -513,6 +515,7 @@ async def create_shot_replicate_project(db: AsyncSession, current_user: User, re
|
||||
input_data={
|
||||
"material_video_url": req.material_video_url,
|
||||
"material_image_url": req.material_image_url,
|
||||
"material_image_resource_id": getattr(req, "material_image_resource_id", None),
|
||||
"source_project_name": req.source_project_name,
|
||||
"target_project_name": req.target_project_name,
|
||||
"core_content_point": req.core_content_point,
|
||||
@@ -1145,6 +1148,10 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
|
||||
video_config=video_config,
|
||||
target_platform=target_platform,
|
||||
schema_config_snapshot=schema_config_snapshot,
|
||||
module=project.module,
|
||||
project_id=project.id,
|
||||
step_id=step.id,
|
||||
trace_id=f"shot-video-prompt:{step.id}",
|
||||
)
|
||||
billing = await charge_module_prompt_usage(
|
||||
db,
|
||||
@@ -1489,7 +1496,13 @@ async def delete_shot_replicate_project(
|
||||
project_id: str,
|
||||
refund_unfinished: bool = False,
|
||||
) -> ShotReplicateDeleteOut:
|
||||
"""内部 helper:只软删除指定 ModuleGenerationProject 自身。
|
||||
|
||||
这里不是对外 API,不反查 ShotReplicateSegment / ShotReplicateTaskSet,
|
||||
不 commit、不 rollback、不删除真实 UploadResource 文件。
|
||||
"""
|
||||
project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True)
|
||||
project_id_snapshot = project.id
|
||||
deleted_at = _now()
|
||||
release_stats: dict[str, int] = {"released_size_bytes": 0}
|
||||
|
||||
@@ -1505,21 +1518,34 @@ async def delete_shot_replicate_project(
|
||||
refund_unfinished=refund_unfinished,
|
||||
release_stats=release_stats,
|
||||
)
|
||||
upload_release = await release_upload_resources_by_source(
|
||||
db,
|
||||
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
|
||||
source_ids=[project_id_snapshot],
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
)
|
||||
pending_ids = list(upload_release.get("released_resource_ids") or [])
|
||||
generated_released = int(release_stats.get("released_size_bytes", 0) or 0)
|
||||
upload_released_size = int(upload_release.get("released_size_bytes") or 0)
|
||||
await log_module_event(
|
||||
db,
|
||||
project=project,
|
||||
event_type=ModuleEventTypeEnum.PROJECT_DELETED.value,
|
||||
message="软删除拆镜复刻项目",
|
||||
message="软删除拆镜复刻内部项目",
|
||||
detail={
|
||||
"refund_unfinished": refund_unfinished,
|
||||
"released_size_bytes": int(release_stats.get("released_size_bytes", 0)),
|
||||
"generated_resource_released_size_bytes": generated_released,
|
||||
"upload_resource_release": {k: v for k, v in upload_release.items() if k != "released_resource_ids"},
|
||||
"pending_delete_resource_count": len(pending_ids),
|
||||
},
|
||||
)
|
||||
return ShotReplicateDeleteOut(
|
||||
message="项目已删除",
|
||||
project_id=project.id,
|
||||
project_id=project_id_snapshot,
|
||||
deleted=True,
|
||||
released_size_bytes=int(release_stats.get("released_size_bytes", 0)),
|
||||
released_size_bytes=generated_released + upload_released_size,
|
||||
upload_resource_released=int(upload_release.get("released") or 0),
|
||||
pending_delete_resource_ids=pending_ids,
|
||||
)
|
||||
|
||||
|
||||
@@ -1579,6 +1605,7 @@ async def create_shot_replicate_project_from_segment(
|
||||
"material_video_url": segment.segment_video_url,
|
||||
"material_video_locked": True,
|
||||
"material_image_url": req.material_image_url,
|
||||
"material_image_resource_id": getattr(req, "material_image_resource_id", None),
|
||||
"source_project_name": segment.segment_category or segment.original_video_category or "拆镜片段",
|
||||
"target_project_name": req.target_project_name,
|
||||
"core_content_point": req.core_content_point,
|
||||
@@ -1622,6 +1649,16 @@ async def create_shot_replicate_project_from_segment(
|
||||
)
|
||||
project.current_step_code = ShotReplicateStepCodeEnum.MATERIAL_INPUT.value
|
||||
project.status = ModuleProjectStatusEnum.WAITING_USER.value
|
||||
await bind_upload_resources(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
|
||||
source_id=project.id,
|
||||
resource_ids=[getattr(req, "material_image_resource_id", None)],
|
||||
urls=[req.material_image_url, segment.segment_video_url],
|
||||
allow_common_migrate=True,
|
||||
)
|
||||
segment.module_project_id = project.id
|
||||
segment.replicate_status = ShotSegmentReplicateStatusEnum.PROJECT_CREATED.value
|
||||
await log_module_event(
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.enums.shot_replicate import (
|
||||
ShotAnalysisStatusEnum,
|
||||
ShotSegmentAnalysisStatusEnum,
|
||||
ShotSegmentReplicateStatusEnum,
|
||||
ShotReplicateLogEventEnum,
|
||||
ShotSegmentSourceModeEnum,
|
||||
ShotSplitStatusEnum,
|
||||
ShotTaskSetStatusEnum,
|
||||
@@ -28,18 +29,22 @@ from app.schemas.shot_replicate import (
|
||||
ShotSegmentDeleteOut,
|
||||
ShotSegmentDetailOut,
|
||||
ShotSegmentListOut,
|
||||
ShotReanalyzeOut,
|
||||
ShotSegmentOut,
|
||||
ShotSplitByAIOut,
|
||||
ShotSplitByAIRequest,
|
||||
ShotSplitCustomOut,
|
||||
ShotSplitCustomRequest,
|
||||
ShotTaskSetCreate,
|
||||
ShotTaskSetDeleteOut,
|
||||
ShotTaskSetDetailOut,
|
||||
ShotTaskSetListOut,
|
||||
ShotTaskSetOut,
|
||||
)
|
||||
from app.services.module_generation_log_service import log_module_event_file
|
||||
from app.services.resource_accounting_service import SOURCE_MODEL_SHOT_SEGMENT, soft_delete_resources_by_source
|
||||
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum
|
||||
from app.services.upload_resource import release_upload_resources_by_source
|
||||
from app.services.upload_video_asset_service import (
|
||||
build_time_node,
|
||||
validate_split_range,
|
||||
@@ -665,6 +670,15 @@ async def delete_segment(
|
||||
source_ids=[segment.id],
|
||||
deleted_at=deleted_at,
|
||||
)
|
||||
upload_release = await release_upload_resources_by_source(
|
||||
db,
|
||||
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_SEGMENT.value,
|
||||
source_ids=[segment.id],
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
)
|
||||
pending_delete_resource_ids: list[str] = list(upload_release.get("released_resource_ids") or [])
|
||||
released_size_bytes += int(upload_release.get("released_size_bytes") or 0)
|
||||
upload_resource_released = int(upload_release.get("released") or 0)
|
||||
|
||||
deleted_module_project_id: str | None = None
|
||||
if module_project_id:
|
||||
@@ -678,6 +692,8 @@ async def delete_segment(
|
||||
)
|
||||
deleted_module_project_id = project_delete_out.project_id
|
||||
released_size_bytes += int(project_delete_out.released_size_bytes or 0)
|
||||
upload_resource_released += int(project_delete_out.upload_resource_released or 0)
|
||||
pending_delete_resource_ids.extend(project_delete_out.pending_delete_resource_ids or [])
|
||||
|
||||
segment.deleted_at = deleted_at
|
||||
segment.replicate_status = (
|
||||
@@ -702,7 +718,9 @@ async def delete_segment(
|
||||
"module_project_id": module_project_id,
|
||||
"deleted_module_project_id": deleted_module_project_id,
|
||||
"released_size_bytes": released_size_bytes,
|
||||
"physical_file_deleted": False,
|
||||
"upload_resource_release": {k: v for k, v in upload_release.items() if k != "released_resource_ids"},
|
||||
"pending_delete_resource_count": len(pending_delete_resource_ids),
|
||||
"physical_file_delete": "after_commit",
|
||||
"refund": False,
|
||||
},
|
||||
)
|
||||
@@ -714,5 +732,244 @@ async def delete_segment(
|
||||
deleted=True,
|
||||
deleted_module_project_id=deleted_module_project_id,
|
||||
released_size_bytes=int(released_size_bytes or 0),
|
||||
upload_resource_released=upload_resource_released,
|
||||
pending_delete_resource_ids=pending_delete_resource_ids,
|
||||
)
|
||||
|
||||
|
||||
async def delete_task_set(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
task_set_id: str,
|
||||
) -> ShotTaskSetDeleteOut:
|
||||
"""软删除整个拆镜任务集。
|
||||
|
||||
对外删除入口以 ShotReplicateTaskSet 为边界;内部 ModuleGenerationProject
|
||||
只作为片段复刻链路被联动软删。这里不 commit、不 rollback、不删除真实文件。
|
||||
"""
|
||||
task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user, for_update=True)
|
||||
task_set_id_snapshot = task_set.id
|
||||
user_id_snapshot = task_set.user_id
|
||||
|
||||
if task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value:
|
||||
raise HTTPException(status_code=400, detail="原视频分析正在处理中,暂不能删除任务集")
|
||||
if task_set.split_status == ShotSplitStatusEnum.PROCESSING.value:
|
||||
raise HTTPException(status_code=400, detail="拆镜切片正在处理中,暂不能删除任务集")
|
||||
|
||||
segments_result = await db.execute(
|
||||
select(ShotReplicateSegment)
|
||||
.where(
|
||||
ShotReplicateSegment.task_set_id == task_set_id_snapshot,
|
||||
ShotReplicateSegment.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
segments = list(segments_result.scalars().all())
|
||||
for segment in segments:
|
||||
if segment.split_status == ShotSplitStatusEnum.PROCESSING.value:
|
||||
raise HTTPException(status_code=400, detail=f"片段{segment.segment_index}正在切割处理中,暂不能删除任务集")
|
||||
if segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value:
|
||||
raise HTTPException(status_code=400, detail=f"片段{segment.segment_index}正在分析处理中,暂不能删除任务集")
|
||||
if segment.replicate_status == ShotSegmentReplicateStatusEnum.PROCESSING.value:
|
||||
raise HTTPException(status_code=400, detail=f"片段{segment.segment_index}关联复刻流程正在处理中,暂不能删除任务集")
|
||||
|
||||
segment_ids = [segment.id for segment in segments]
|
||||
module_project_ids = [segment.module_project_id for segment in segments if segment.module_project_id]
|
||||
deleted_at = _now()
|
||||
released_size_bytes = 0
|
||||
upload_resource_released = 0
|
||||
pending_delete_resource_ids: list[str] = []
|
||||
|
||||
task_upload_release = await release_upload_resources_by_source(
|
||||
db,
|
||||
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_TASK_SET.value,
|
||||
source_ids=[task_set_id_snapshot],
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
)
|
||||
released_size_bytes += int(task_upload_release.get("released_size_bytes") or 0)
|
||||
upload_resource_released += int(task_upload_release.get("released") or 0)
|
||||
pending_delete_resource_ids.extend(task_upload_release.get("released_resource_ids") or [])
|
||||
|
||||
segment_upload_release = await release_upload_resources_by_source(
|
||||
db,
|
||||
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_SEGMENT.value,
|
||||
source_ids=segment_ids,
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
)
|
||||
released_size_bytes += int(segment_upload_release.get("released_size_bytes") or 0)
|
||||
upload_resource_released += int(segment_upload_release.get("released") or 0)
|
||||
pending_delete_resource_ids.extend(segment_upload_release.get("released_resource_ids") or [])
|
||||
|
||||
from app.services.shot_replicate_flow_service import delete_shot_replicate_project
|
||||
|
||||
deleted_module_project_count = 0
|
||||
for module_project_id in dict.fromkeys(module_project_ids):
|
||||
project_delete_out = await delete_shot_replicate_project(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=module_project_id,
|
||||
refund_unfinished=False,
|
||||
)
|
||||
deleted_module_project_count += 1
|
||||
released_size_bytes += int(project_delete_out.released_size_bytes or 0)
|
||||
upload_resource_released += int(project_delete_out.upload_resource_released or 0)
|
||||
pending_delete_resource_ids.extend(project_delete_out.pending_delete_resource_ids or [])
|
||||
|
||||
task_set.deleted_at = deleted_at
|
||||
task_set.status = ShotTaskSetStatusEnum.DELETED.value
|
||||
for segment in segments:
|
||||
segment.deleted_at = deleted_at
|
||||
segment.replicate_status = ShotSegmentReplicateStatusEnum.FAILED.value if segment.module_project_id else segment.replicate_status
|
||||
|
||||
await db.flush()
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type="SHOT_TASK_SET_DELETED",
|
||||
project_id=task_set_id_snapshot,
|
||||
user_id=user_id_snapshot,
|
||||
message="软删除拆镜任务集并标记上传资源待物理删除",
|
||||
detail={
|
||||
"task_set_id": task_set_id_snapshot,
|
||||
"segment_count": len(segment_ids),
|
||||
"module_project_count": deleted_module_project_count,
|
||||
"released_size_bytes": released_size_bytes,
|
||||
"upload_resource_released": upload_resource_released,
|
||||
"pending_delete_resource_count": len(pending_delete_resource_ids),
|
||||
"task_upload_release": {k: v for k, v in task_upload_release.items() if k != "released_resource_ids"},
|
||||
"segment_upload_release": {k: v for k, v in segment_upload_release.items() if k != "released_resource_ids"},
|
||||
"physical_file_delete": "after_commit",
|
||||
"refund": False,
|
||||
},
|
||||
)
|
||||
return ShotTaskSetDeleteOut(
|
||||
message="拆镜任务集已删除",
|
||||
task_set_id=task_set_id_snapshot,
|
||||
deleted=True,
|
||||
deleted_segment_count=len(segment_ids),
|
||||
deleted_module_project_count=deleted_module_project_count,
|
||||
released_size_bytes=int(released_size_bytes or 0),
|
||||
upload_resource_released=upload_resource_released,
|
||||
pending_delete_resource_ids=pending_delete_resource_ids,
|
||||
)
|
||||
|
||||
|
||||
async def prepare_reanalyze_task_set(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
task_set_id: str,
|
||||
force: bool = False,
|
||||
reason: str | None = None,
|
||||
) -> ShotReanalyzeOut:
|
||||
"""重置原视频分析状态,供 API 重新投递 Celery。"""
|
||||
task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user, for_update=True)
|
||||
if task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value:
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_REJECTED.value,
|
||||
project_id=task_set.id,
|
||||
user_id=task_set.user_id,
|
||||
message="原视频分析正在处理中,拒绝再次分析",
|
||||
detail={"task_set_id": task_set.id, "analysis_status": task_set.analysis_status, "reason": reason},
|
||||
event_status="rejected",
|
||||
)
|
||||
raise HTTPException(status_code=409, detail="原视频分析正在处理中,不能重复投递")
|
||||
if task_set.analysis_status == ShotAnalysisStatusEnum.COMPLETED.value and not force:
|
||||
raise HTTPException(status_code=409, detail="原视频分析已完成,如确需重跑请传 force=true")
|
||||
if force:
|
||||
active_segments_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(ShotReplicateSegment)
|
||||
.where(
|
||||
ShotReplicateSegment.task_set_id == task_set.id,
|
||||
ShotReplicateSegment.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if int(active_segments_result.scalar() or 0) > 0:
|
||||
raise HTTPException(status_code=409, detail="当前总任务集已存在拆镜片段,不能强制重跑原视频分析")
|
||||
|
||||
task_set.status = ShotTaskSetStatusEnum.PENDING_ANALYSIS.value
|
||||
task_set.analysis_status = ShotAnalysisStatusEnum.PENDING.value
|
||||
task_set.analysis_error_message = None
|
||||
task_set.original_video_content = None
|
||||
task_set.original_video_category = None
|
||||
task_set.original_video_audience = None
|
||||
task_set.ai_suggestion_json = None
|
||||
task_set.analysis_raw_json = None
|
||||
task_set.analysis_result_json = None
|
||||
await db.flush()
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_RECEIVED.value,
|
||||
project_id=task_set.id,
|
||||
user_id=task_set.user_id,
|
||||
message="原视频再次分析已重置状态",
|
||||
detail={"task_set_id": task_set.id, "force": force, "reason": reason, "video_url": task_set.video_url},
|
||||
)
|
||||
return ShotReanalyzeOut(
|
||||
message="原视频再次分析任务已准备投递",
|
||||
task_set_id=task_set.id,
|
||||
segment_id=None,
|
||||
analysis_status=task_set.analysis_status,
|
||||
celery_task_name="shot_replicate.analyze_original_video",
|
||||
)
|
||||
|
||||
|
||||
async def prepare_reanalyze_segment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
segment_id: str,
|
||||
force: bool = False,
|
||||
reason: str | None = None,
|
||||
) -> ShotReanalyzeOut:
|
||||
"""重置自定义切片视频分析状态,供 API 重新投递 Celery。"""
|
||||
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True)
|
||||
if segment.split_status != ShotSplitStatusEnum.COMPLETED.value:
|
||||
raise HTTPException(status_code=409, detail="当前片段还未切割完成,不能再次分析")
|
||||
if not segment.segment_video_url:
|
||||
raise HTTPException(status_code=409, detail="当前片段缺少 segment_video_url,不能再次分析")
|
||||
if segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value:
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_REJECTED.value,
|
||||
project_id=segment.task_set_id,
|
||||
step_id=segment.id,
|
||||
user_id=segment.user_id,
|
||||
message="切片视频分析正在处理中,拒绝再次分析",
|
||||
detail={"segment_id": segment.id, "analysis_status": segment.analysis_status, "reason": reason},
|
||||
event_status="rejected",
|
||||
)
|
||||
raise HTTPException(status_code=409, detail="切片视频分析正在处理中,不能重复投递")
|
||||
if segment.analysis_status == ShotSegmentAnalysisStatusEnum.COMPLETED.value and not force:
|
||||
raise HTTPException(status_code=409, detail="切片视频分析已完成,如确需重跑请传 force=true")
|
||||
if segment.source_mode != ShotSegmentSourceModeEnum.CUSTOM.value and not force:
|
||||
raise HTTPException(status_code=409, detail="AI 建议片段默认无需单独分析,如确需重跑请传 force=true")
|
||||
|
||||
segment.analysis_status = ShotSegmentAnalysisStatusEnum.PENDING.value
|
||||
segment.analysis_error_message = None
|
||||
segment.analysis_json = None
|
||||
segment.original_video_content = None
|
||||
segment.original_video_category = None
|
||||
segment.original_video_audience = None
|
||||
segment.segment_content = None
|
||||
segment.segment_category = None
|
||||
segment.segment_audience = None
|
||||
await db.flush()
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_RECEIVED.value,
|
||||
project_id=segment.task_set_id,
|
||||
step_id=segment.id,
|
||||
user_id=segment.user_id,
|
||||
message="切片视频再次分析已重置状态",
|
||||
detail={"segment_id": segment.id, "task_set_id": segment.task_set_id, "force": force, "reason": reason, "video_url": segment.segment_video_url},
|
||||
)
|
||||
return ShotReanalyzeOut(
|
||||
message="切片视频再次分析任务已准备投递",
|
||||
task_set_id=segment.task_set_id,
|
||||
segment_id=segment.id,
|
||||
analysis_status=segment.analysis_status,
|
||||
celery_task_name="shot_replicate.analyze_custom_segment_video",
|
||||
)
|
||||
|
||||
@@ -19,6 +19,9 @@ from app.models.token_usage import TokenUsage
|
||||
from app.services.upload_video_asset_service import resolve_upload_video_path
|
||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
from app.utils.id_gen import generate_id
|
||||
from app.enums.common import LogEventStatusEnum, LogSourceEnum
|
||||
from app.enums.shot_replicate import ModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum
|
||||
from app.services.operation_log_service import log_ai_model_event
|
||||
|
||||
AnalysisMode = Literal["full_breakdown", "summary_only"]
|
||||
|
||||
@@ -74,7 +77,7 @@ def build_file_url_or_data_uri(file_url: str, fallback_mime: str = "video/mp4")
|
||||
# return f"data:{mime};base64,{b64}"
|
||||
|
||||
|
||||
def build_user_message(user_text: str, video_url: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
def build_user_message(user_text: str, video_url: str) -> tuple[dict[str, Any], dict[str, Any], str]:
|
||||
real_url = build_file_url_or_data_uri(video_url)
|
||||
content_parts = [
|
||||
{
|
||||
@@ -96,7 +99,7 @@ def build_user_message(user_text: str, video_url: str) -> tuple[dict[str, Any],
|
||||
},
|
||||
{"type": "text", "text": user_text},
|
||||
]
|
||||
return {"role": "user", "content": content_parts}, {"role": "user", "content": log_content_parts}
|
||||
return {"role": "user", "content": content_parts}, {"role": "user", "content": log_content_parts}, real_url
|
||||
|
||||
|
||||
def build_video_analysis_system_prompt(*, mode: AnalysisMode) -> str:
|
||||
@@ -420,12 +423,106 @@ def _int_usage(value: Any) -> int:
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
def _safe_response_json(response: httpx.Response | None) -> Any:
|
||||
if response is None:
|
||||
return None
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return {"raw_text": response.text}
|
||||
|
||||
|
||||
def _extract_remote_error(data: Any) -> tuple[str | None, str | None, str | None, str | None]:
|
||||
"""Return remote_request_id, remote_code, remote_message, remote_param."""
|
||||
if isinstance(data, dict):
|
||||
err = data.get("error") if isinstance(data.get("error"), dict) else data
|
||||
remote_code = err.get("code") or err.get("type") if isinstance(err, dict) else None
|
||||
remote_message = err.get("message") if isinstance(err, dict) else None
|
||||
remote_param = err.get("param") if isinstance(err, dict) else None
|
||||
remote_request_id = err.get("request_id") or data.get("request_id") if isinstance(err, dict) else data.get("request_id")
|
||||
if not remote_request_id and isinstance(remote_message, str):
|
||||
match = re.search(r"Request id:\s*([a-zA-Z0-9_.:-]+)", remote_message, flags=re.IGNORECASE)
|
||||
if match:
|
||||
remote_request_id = match.group(1)
|
||||
return (
|
||||
str(remote_request_id) if remote_request_id else None,
|
||||
str(remote_code) if remote_code else None,
|
||||
str(remote_message) if remote_message else None,
|
||||
str(remote_param) if remote_param else None,
|
||||
)
|
||||
return None, None, str(data) if data is not None else None, None
|
||||
|
||||
|
||||
def _log_shot_ai_model_event(
|
||||
*,
|
||||
event_type: str,
|
||||
event_status: str,
|
||||
config: ModelConfig,
|
||||
trace_id: str,
|
||||
user_id: str | None,
|
||||
task_set_id: str | None,
|
||||
segment_id: str | None,
|
||||
mode: AnalysisMode,
|
||||
request_data: dict[str, Any] | None = None,
|
||||
response_data: Any = None,
|
||||
token_usage: dict[str, Any] | None = None,
|
||||
http_status: int | None = None,
|
||||
remote_request_id: str | None = None,
|
||||
remote_code: str | None = None,
|
||||
remote_message: str | None = None,
|
||||
remote_param: str | None = None,
|
||||
message: str | None = None,
|
||||
error: str | None = None,
|
||||
extra_detail: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
action = (
|
||||
ShotReplicateRemoteActionEnum.ANALYZE_ORIGINAL_VIDEO.value
|
||||
if mode == "full_breakdown"
|
||||
else ShotReplicateRemoteActionEnum.ANALYZE_CUSTOM_SEGMENT_VIDEO.value
|
||||
)
|
||||
detail = dict(extra_detail or {})
|
||||
detail.update({
|
||||
"analysis_mode": mode,
|
||||
"remote_code": remote_code,
|
||||
"remote_message": remote_message,
|
||||
"remote_param": remote_param,
|
||||
})
|
||||
log_ai_model_event(
|
||||
event_type=event_type,
|
||||
event_status=event_status,
|
||||
source=LogSourceEnum.REMOTE_API.value,
|
||||
module=ModuleCodeEnum.SHOT_REPLICATE.value,
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
project_id=task_set_id,
|
||||
step_id=segment_id,
|
||||
remote_action=action,
|
||||
remote_request_id=remote_request_id,
|
||||
model_config_id=str(config.id),
|
||||
model_config_name=config.name,
|
||||
model_name=config.model_name,
|
||||
provider=config.provider,
|
||||
api_base=config.api_base,
|
||||
http_status=http_status,
|
||||
request=request_data,
|
||||
response=response_data,
|
||||
token_usage=token_usage,
|
||||
message=message,
|
||||
detail=detail,
|
||||
error=error,
|
||||
)
|
||||
|
||||
async def analyze_video_for_shot_split(
|
||||
db: AsyncSession,
|
||||
video_url: str,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
mode: AnalysisMode = "full_breakdown",
|
||||
task_set_id: str | None = None,
|
||||
segment_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
) -> ShotVideoAnalysisResult:
|
||||
"""调用模型完成拆镜/片段分析。
|
||||
|
||||
@@ -433,6 +530,7 @@ async def analyze_video_for_shot_split(
|
||||
不再读取 SHOT_ANALYSIS_API_BASE / SHOT_ANALYSIS_API_KEY / SHOT_ANALYSIS_MODEL_NAME,
|
||||
也不再 fallback 到 SEEDANCE_*,避免拆镜分析走错通道。
|
||||
"""
|
||||
trace_id = trace_id or generate_id()
|
||||
config = await _select_model_config(db)
|
||||
if not config:
|
||||
raise RuntimeError("拆镜分析模型未配置:请先在 model_configs 表启用可用模型")
|
||||
@@ -445,7 +543,7 @@ async def analyze_video_for_shot_split(
|
||||
|
||||
system_prompt = build_video_analysis_system_prompt(mode=mode)
|
||||
user_text = build_video_analysis_user_text(mode=mode)
|
||||
user_message, log_user_message = build_user_message(user_text, video_url)
|
||||
user_message, log_user_message, real_video_url = build_user_message(user_text, video_url)
|
||||
|
||||
request_data: dict[str, Any] = {
|
||||
"model": config.model_name,
|
||||
@@ -467,21 +565,128 @@ async def analyze_video_for_shot_split(
|
||||
"model_config_name": config.name,
|
||||
"provider": config.provider,
|
||||
"analysis_mode": mode,
|
||||
"video_url": video_url,
|
||||
"signed_video_url": real_video_url,
|
||||
"video_fps": _video_fps(),
|
||||
"timeout_seconds": _timeout_seconds(),
|
||||
}
|
||||
|
||||
url = f"{str(config.api_base).rstrip('/')}/chat/completions"
|
||||
async with httpx.AsyncClient(timeout=_timeout_seconds()) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"},
|
||||
json=request_data,
|
||||
_log_shot_ai_model_event(
|
||||
event_type=(
|
||||
ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_STARTED.value
|
||||
if mode == "full_breakdown"
|
||||
else ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_REMOTE_API_STARTED.value
|
||||
),
|
||||
event_status=LogEventStatusEnum.STARTED.value,
|
||||
config=config,
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
task_set_id=task_set_id,
|
||||
segment_id=segment_id,
|
||||
mode=mode,
|
||||
request_data={**log_request_data, "api_url": url},
|
||||
message="拆镜视频分析模型请求开始",
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_timeout_seconds()) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"},
|
||||
json=request_data,
|
||||
)
|
||||
except Exception as exc:
|
||||
_log_shot_ai_model_event(
|
||||
event_type=(
|
||||
ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_FAILED.value
|
||||
if mode == "full_breakdown"
|
||||
else ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_REMOTE_API_FAILED.value
|
||||
),
|
||||
event_status=LogEventStatusEnum.FAILED.value,
|
||||
config=config,
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
task_set_id=task_set_id,
|
||||
segment_id=segment_id,
|
||||
mode=mode,
|
||||
request_data={**log_request_data, "api_url": url},
|
||||
message="拆镜视频分析模型请求异常",
|
||||
error=str(exc),
|
||||
extra_detail={"exception_type": type(exc).__name__},
|
||||
)
|
||||
raise
|
||||
|
||||
response_data = _safe_response_json(response)
|
||||
remote_request_id, remote_code, remote_message, remote_param = _extract_remote_error(response_data)
|
||||
if response.status_code >= 400:
|
||||
_log_shot_ai_model_event(
|
||||
event_type=(
|
||||
ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_FAILED.value
|
||||
if mode == "full_breakdown"
|
||||
else ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_REMOTE_API_FAILED.value
|
||||
),
|
||||
event_status=LogEventStatusEnum.FAILED.value,
|
||||
config=config,
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
task_set_id=task_set_id,
|
||||
segment_id=segment_id,
|
||||
mode=mode,
|
||||
request_data={**log_request_data, "api_url": url},
|
||||
response_data=response_data,
|
||||
http_status=response.status_code,
|
||||
remote_request_id=remote_request_id,
|
||||
remote_code=remote_code,
|
||||
remote_message=remote_message,
|
||||
remote_param=remote_param,
|
||||
message="拆镜视频分析模型请求失败",
|
||||
error=f"HTTP {response.status_code}: {response.text}",
|
||||
)
|
||||
raise RuntimeError(f"视频拆镜分析 API 请求失败: HTTP {response.status_code}: {response.text}")
|
||||
|
||||
raw = response.json()
|
||||
content = get_message_content_or_raise(raw)
|
||||
result = parse_model_json(content)
|
||||
try:
|
||||
raw = response.json()
|
||||
except Exception as exc:
|
||||
_log_shot_ai_model_event(
|
||||
event_type=ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value,
|
||||
event_status=LogEventStatusEnum.FAILED.value,
|
||||
config=config,
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
task_set_id=task_set_id,
|
||||
segment_id=segment_id,
|
||||
mode=mode,
|
||||
request_data={**log_request_data, "api_url": url},
|
||||
response_data={"raw_text": response.text},
|
||||
http_status=response.status_code,
|
||||
message="拆镜视频分析模型响应 JSON 解析失败",
|
||||
error=str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
try:
|
||||
content = get_message_content_or_raise(raw)
|
||||
result = parse_model_json(content)
|
||||
except Exception as exc:
|
||||
event_type = ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_EMPTY.value if "content 为空" in str(exc) else ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value
|
||||
_log_shot_ai_model_event(
|
||||
event_type=event_type,
|
||||
event_status=LogEventStatusEnum.FAILED.value,
|
||||
config=config,
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
task_set_id=task_set_id,
|
||||
segment_id=segment_id,
|
||||
mode=mode,
|
||||
request_data={**log_request_data, "api_url": url},
|
||||
response_data=raw,
|
||||
http_status=response.status_code,
|
||||
message="拆镜视频分析模型内容解析失败",
|
||||
error=str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
result = fill_none_with_wu(result)
|
||||
result = ensure_result_schema(result)
|
||||
result = filter_and_normalize_breakdown(result, mode=mode)
|
||||
@@ -500,6 +705,7 @@ async def analyze_video_for_shot_split(
|
||||
"split_min_seconds": _split_min_seconds(),
|
||||
"split_max_seconds": _split_max_seconds(),
|
||||
"analysis_mode": mode,
|
||||
"trace_id": trace_id,
|
||||
"log_request": log_request_data,
|
||||
}
|
||||
if not token_usage["total_tokens"]:
|
||||
@@ -525,5 +731,25 @@ async def analyze_video_for_shot_split(
|
||||
"model_name": config.model_name,
|
||||
})
|
||||
|
||||
_log_shot_ai_model_event(
|
||||
event_type=(
|
||||
ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_SUCCESS.value
|
||||
if mode == "full_breakdown"
|
||||
else ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_REMOTE_API_SUCCESS.value
|
||||
),
|
||||
event_status=LogEventStatusEnum.SUCCESS.value,
|
||||
config=config,
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
task_set_id=task_set_id,
|
||||
segment_id=segment_id,
|
||||
mode=mode,
|
||||
request_data={**log_request_data, "api_url": url},
|
||||
response_data=raw,
|
||||
token_usage=token_usage,
|
||||
http_status=response.status_code,
|
||||
message="拆镜视频分析模型请求成功",
|
||||
)
|
||||
|
||||
return ShotVideoAnalysisResult(result=result, raw_response=raw, usage=token_usage)
|
||||
|
||||
|
||||
@@ -222,6 +222,40 @@ async def get_pending_requests(db: AsyncSession, team_id: str) -> list[dict[str,
|
||||
"status": req.status,
|
||||
"note": req.note,
|
||||
"created_at": req.created_at,
|
||||
"handled_at": req.updated_at if req.status != "pending" else None,
|
||||
}
|
||||
for req, username, phone in rows
|
||||
]
|
||||
|
||||
|
||||
async def get_all_requests(
|
||||
db: AsyncSession,
|
||||
team_id: str,
|
||||
status: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""获取团队所有加入申请列表,支持按状态筛选。"""
|
||||
where = [TeamJoinRequest.team_id == team_id]
|
||||
if status:
|
||||
where.append(TeamJoinRequest.status == status)
|
||||
|
||||
result = await db.execute(
|
||||
select(TeamJoinRequest, User.username, User.phone)
|
||||
.join(User, User.id == TeamJoinRequest.user_id)
|
||||
.where(*where)
|
||||
.order_by(TeamJoinRequest.created_at.desc())
|
||||
)
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"id": req.id,
|
||||
"team_id": req.team_id,
|
||||
"user_id": req.user_id,
|
||||
"username": username,
|
||||
"phone": phone,
|
||||
"status": req.status,
|
||||
"note": req.note,
|
||||
"created_at": req.created_at,
|
||||
"handled_at": req.updated_at if req.status != "pending" else None,
|
||||
}
|
||||
for req, username, phone in rows
|
||||
]
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from app.services.upload_resource.core_service import (
|
||||
delete_unbound_upload_resource,
|
||||
record_external_upload_resource,
|
||||
upload_reference_file,
|
||||
)
|
||||
from app.services.upload_resource.bind_service import (
|
||||
bind_upload_resources,
|
||||
release_upload_resources_by_source,
|
||||
record_shot_segment_upload_resource,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"upload_reference_file",
|
||||
"delete_unbound_upload_resource",
|
||||
"record_external_upload_resource",
|
||||
"bind_upload_resources",
|
||||
"release_upload_resources_by_source",
|
||||
"record_shot_segment_upload_resource",
|
||||
"list_upload_resource_history_grouped_days",
|
||||
"list_upload_resource_history_day_items",
|
||||
"mark_upload_resource_history_deleted",
|
||||
"cleanup_upload_resource_history_files",
|
||||
]
|
||||
|
||||
from app.services.upload_resource.file_cleanup_service import (
|
||||
cleanup_pending_upload_resource_files,
|
||||
cleanup_upload_resource_files_after_commit,
|
||||
)
|
||||
|
||||
from app.services.upload_resource.history_service import (
|
||||
list_upload_resource_history_grouped_days,
|
||||
list_upload_resource_history_day_items,
|
||||
)
|
||||
from app.services.upload_resource.delete_service import (
|
||||
cleanup_upload_resource_history_files,
|
||||
mark_upload_resource_history_deleted,
|
||||
)
|
||||
@@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.upload_resource import UploadResourceTypeEnum
|
||||
from app.models.generated_resource import GeneratedResource
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.models.user_resource_month_stat import UserResourceMonthStat
|
||||
from app.models.user_resource_total_stat import UserResourceTotalStat
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
|
||||
def resource_month_from_datetime(value: datetime | None = None) -> date:
|
||||
value = value or datetime.now(timezone.utc)
|
||||
return date(value.year, value.month, 1)
|
||||
|
||||
|
||||
def _int(value: Any) -> int:
|
||||
return int(value or 0)
|
||||
|
||||
|
||||
def _add_non_negative(obj: Any, field: str, delta: int) -> None:
|
||||
current = _int(getattr(obj, field, 0))
|
||||
setattr(obj, field, max(0, current + int(delta or 0)))
|
||||
|
||||
|
||||
def _add_raw(obj: Any, field: str, delta: int) -> None:
|
||||
current = _int(getattr(obj, field, 0))
|
||||
setattr(obj, field, current + int(delta or 0))
|
||||
|
||||
|
||||
async def get_or_create_month_stat(db: AsyncSession, user_id: str, stat_month: date) -> UserResourceMonthStat:
|
||||
result = await db.execute(
|
||||
select(UserResourceMonthStat).where(
|
||||
UserResourceMonthStat.user_id == user_id,
|
||||
UserResourceMonthStat.stat_month == stat_month,
|
||||
).limit(1)
|
||||
)
|
||||
stat = result.scalar_one_or_none()
|
||||
if stat:
|
||||
return stat
|
||||
stat = UserResourceMonthStat(id=generate_id(), user_id=user_id, stat_month=stat_month)
|
||||
db.add(stat)
|
||||
await db.flush()
|
||||
return stat
|
||||
|
||||
|
||||
async def get_or_create_total_stat(db: AsyncSession, user_id: str, *, for_update: bool = False) -> UserResourceTotalStat:
|
||||
stmt = select(UserResourceTotalStat).where(UserResourceTotalStat.user_id == user_id).limit(1)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
result = await db.execute(stmt)
|
||||
stat = result.scalar_one_or_none()
|
||||
if stat:
|
||||
return stat
|
||||
stat = UserResourceTotalStat(id=generate_id(), user_id=user_id)
|
||||
db.add(stat)
|
||||
await db.flush()
|
||||
if for_update:
|
||||
result = await db.execute(
|
||||
select(UserResourceTotalStat)
|
||||
.where(UserResourceTotalStat.user_id == user_id)
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
locked = result.scalar_one_or_none()
|
||||
if locked:
|
||||
return locked
|
||||
return stat
|
||||
|
||||
|
||||
async def apply_upload_resource_stat_delta(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
stat_month: date,
|
||||
resource_type: str,
|
||||
active_size_delta: int = 0,
|
||||
active_count_delta: int = 0,
|
||||
deleted_size_delta: int = 0,
|
||||
deleted_count_delta: int = 0,
|
||||
upload_size_delta: int = 0,
|
||||
upload_count_delta: int = 0,
|
||||
) -> None:
|
||||
month_stat = await get_or_create_month_stat(db, user_id, stat_month)
|
||||
total_stat = await get_or_create_total_stat(db, user_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for stat in (month_stat, total_stat):
|
||||
_add_non_negative(stat, "active_size_bytes", active_size_delta)
|
||||
_add_non_negative(stat, "active_count", active_count_delta)
|
||||
_add_non_negative(stat, "deleted_size_bytes", deleted_size_delta)
|
||||
_add_non_negative(stat, "deleted_count", deleted_count_delta)
|
||||
_add_raw(stat, "upload_size_bytes", upload_size_delta)
|
||||
_add_raw(stat, "upload_count", upload_count_delta)
|
||||
|
||||
if resource_type == UploadResourceTypeEnum.IMAGE.value:
|
||||
_add_non_negative(stat, "image_size_bytes", active_size_delta)
|
||||
_add_non_negative(stat, "image_count", active_count_delta)
|
||||
elif resource_type == UploadResourceTypeEnum.VIDEO.value:
|
||||
_add_non_negative(stat, "video_size_bytes", active_size_delta)
|
||||
_add_non_negative(stat, "video_count", active_count_delta)
|
||||
elif resource_type == UploadResourceTypeEnum.AUDIO.value:
|
||||
_add_non_negative(stat, "audio_size_bytes", active_size_delta)
|
||||
_add_non_negative(stat, "audio_count", active_count_delta)
|
||||
elif resource_type == UploadResourceTypeEnum.SHOT_SEGMENT.value:
|
||||
_add_non_negative(stat, "shot_segment_size_bytes", active_size_delta)
|
||||
_add_non_negative(stat, "shot_segment_count", active_count_delta)
|
||||
|
||||
stat.last_recalculated_at = now
|
||||
|
||||
|
||||
async def release_upload_resource_capacity(db: AsyncSession, resource: UploadResource, *, released_at: datetime | None = None) -> bool:
|
||||
if resource.capacity_released_at is not None:
|
||||
return False
|
||||
released_at = released_at or datetime.now(timezone.utc)
|
||||
stat_month = resource_month_from_datetime(resource.created_at or released_at)
|
||||
size = _int(resource.file_size_bytes)
|
||||
resource.capacity_released_at = released_at
|
||||
await apply_upload_resource_stat_delta(
|
||||
db,
|
||||
user_id=resource.user_id,
|
||||
stat_month=stat_month,
|
||||
resource_type=resource.resource_type,
|
||||
active_size_delta=-size,
|
||||
active_count_delta=-1,
|
||||
deleted_size_delta=size,
|
||||
deleted_count_delta=1,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def rebuild_user_resource_stats(db: AsyncSession, *, user_ids: list[str] | None = None) -> dict[str, int]:
|
||||
"""按数据库真实资源账本重算统计。
|
||||
|
||||
说明:这里只重置并重算 user_ids 范围内的统计。未传 user_ids 时重算所有在资源表中出现过的用户。
|
||||
"""
|
||||
if user_ids is None:
|
||||
ids: set[str] = set()
|
||||
for model in (GeneratedResource, UploadResource):
|
||||
result = await db.execute(select(model.user_id).distinct())
|
||||
ids.update(v for v in result.scalars().all() if v)
|
||||
user_ids = sorted(ids)
|
||||
else:
|
||||
user_ids = sorted({v for v in user_ids if v})
|
||||
if not user_ids:
|
||||
return {"users": 0, "month_rows": 0, "total_rows": 0}
|
||||
|
||||
await db.execute(update(UserResourceTotalStat).where(UserResourceTotalStat.user_id.in_(user_ids)).values(
|
||||
active_size_bytes=0,
|
||||
deleted_size_bytes=0,
|
||||
total_generated_size_bytes=0,
|
||||
upload_size_bytes=0,
|
||||
image_size_bytes=0,
|
||||
video_size_bytes=0,
|
||||
audio_size_bytes=0,
|
||||
shot_segment_size_bytes=0,
|
||||
active_count=0,
|
||||
deleted_count=0,
|
||||
upload_count=0,
|
||||
image_count=0,
|
||||
video_count=0,
|
||||
audio_count=0,
|
||||
shot_segment_count=0,
|
||||
last_recalculated_at=datetime.now(timezone.utc),
|
||||
))
|
||||
await db.execute(update(UserResourceMonthStat).where(UserResourceMonthStat.user_id.in_(user_ids)).values(
|
||||
active_size_bytes=0,
|
||||
deleted_size_bytes=0,
|
||||
total_generated_size_bytes=0,
|
||||
upload_size_bytes=0,
|
||||
image_size_bytes=0,
|
||||
video_size_bytes=0,
|
||||
audio_size_bytes=0,
|
||||
shot_segment_size_bytes=0,
|
||||
active_count=0,
|
||||
deleted_count=0,
|
||||
upload_count=0,
|
||||
image_count=0,
|
||||
video_count=0,
|
||||
audio_count=0,
|
||||
shot_segment_count=0,
|
||||
last_recalculated_at=datetime.now(timezone.utc),
|
||||
))
|
||||
|
||||
month_rows = 0
|
||||
total_rows = 0
|
||||
|
||||
gen_rows = await db.execute(
|
||||
select(
|
||||
GeneratedResource.user_id,
|
||||
GeneratedResource.resource_month,
|
||||
GeneratedResource.resource_type,
|
||||
GeneratedResource.deleted_at,
|
||||
func.count(GeneratedResource.id),
|
||||
func.coalesce(func.sum(GeneratedResource.file_size_bytes), 0),
|
||||
).where(GeneratedResource.user_id.in_(user_ids)).group_by(
|
||||
GeneratedResource.user_id,
|
||||
GeneratedResource.resource_month,
|
||||
GeneratedResource.resource_type,
|
||||
GeneratedResource.deleted_at,
|
||||
)
|
||||
)
|
||||
for user_id, month, rtype, deleted_at, count, size in gen_rows.all():
|
||||
active = deleted_at is None
|
||||
await apply_upload_resource_stat_delta(
|
||||
db,
|
||||
user_id=user_id,
|
||||
stat_month=month,
|
||||
resource_type=rtype,
|
||||
active_size_delta=int(size or 0) if active else 0,
|
||||
active_count_delta=int(count or 0) if active else 0,
|
||||
deleted_size_delta=0 if active else int(size or 0),
|
||||
deleted_count_delta=0 if active else int(count or 0),
|
||||
)
|
||||
# 生成资源字段单独累加
|
||||
month_stat = await get_or_create_month_stat(db, user_id, month)
|
||||
total_stat = await get_or_create_total_stat(db, user_id)
|
||||
if active:
|
||||
_add_raw(month_stat, "total_generated_size_bytes", int(size or 0))
|
||||
_add_raw(total_stat, "total_generated_size_bytes", int(size or 0))
|
||||
|
||||
upload_rows = await db.execute(
|
||||
select(
|
||||
UploadResource.user_id,
|
||||
func.date_trunc("month", UploadResource.created_at).label("month"),
|
||||
UploadResource.resource_type,
|
||||
UploadResource.deleted_at,
|
||||
func.count(UploadResource.id),
|
||||
func.coalesce(func.sum(UploadResource.file_size_bytes), 0),
|
||||
).where(UploadResource.user_id.in_(user_ids)).group_by(
|
||||
UploadResource.user_id,
|
||||
"month",
|
||||
UploadResource.resource_type,
|
||||
UploadResource.deleted_at,
|
||||
)
|
||||
)
|
||||
for user_id, month_dt, rtype, deleted_at, count, size in upload_rows.all():
|
||||
month = resource_month_from_datetime(month_dt or datetime.now(timezone.utc))
|
||||
active = deleted_at is None
|
||||
await apply_upload_resource_stat_delta(
|
||||
db,
|
||||
user_id=user_id,
|
||||
stat_month=month,
|
||||
resource_type=rtype,
|
||||
active_size_delta=int(size or 0) if active else 0,
|
||||
active_count_delta=int(count or 0) if active else 0,
|
||||
deleted_size_delta=0 if active else int(size or 0),
|
||||
deleted_count_delta=0 if active else int(count or 0),
|
||||
upload_size_delta=int(size or 0) if active else 0,
|
||||
upload_count_delta=int(count or 0) if active else 0,
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
total_rows = len(user_ids)
|
||||
month_count = await db.execute(select(func.count(UserResourceMonthStat.id)).where(UserResourceMonthStat.user_id.in_(user_ids)))
|
||||
month_rows = int(month_count.scalar_one() or 0)
|
||||
return {"users": len(user_ids), "month_rows": month_rows, "total_rows": total_rows}
|
||||
@@ -0,0 +1,368 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.upload_resource import (
|
||||
UploadResourceBindStatusEnum,
|
||||
UploadResourceCreatedByEnum,
|
||||
UploadResourceDeletePolicyEnum,
|
||||
UploadResourceEventEnum,
|
||||
UploadResourceModuleEnum,
|
||||
UploadResourceSourceModelEnum,
|
||||
)
|
||||
from app.models.module_generation_project import ModuleGenerationProject
|
||||
from app.models.module_generation_step import ModuleGenerationStep
|
||||
from app.models.shot_replicate_segment import ShotReplicateSegment
|
||||
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.models.user import User
|
||||
from app.services.upload_resource.accounting_service import rebuild_user_resource_stats
|
||||
from app.services.upload_resource.bind_service import bind_upload_resources
|
||||
from app.services.upload_resource.core_service import record_external_upload_resource
|
||||
from app.services.upload_resource.log_service import log_upload_resource_event
|
||||
from app.services.upload_resource.file_cleanup_service import cleanup_pending_upload_resource_files
|
||||
from app.services.upload_resource.path_resolver import ParsedUploadPath, iter_files, parse_upload_path, upload_url_to_storage_path
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BackfillOptions:
|
||||
root: str = "storage/uploads"
|
||||
batch_size: int = 500
|
||||
dry_run: bool = True
|
||||
include_legacy: bool = False
|
||||
rebind_modules: bool = False
|
||||
rebuild_stats: bool = False
|
||||
only_user_id: str | None = None
|
||||
only_module: str | None = None
|
||||
cleanup_pending_files: bool = False
|
||||
cleanup_limit: int = 500
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BackfillResult:
|
||||
scanned: int = 0
|
||||
matched: int = 0
|
||||
inserted: int = 0
|
||||
updated: int = 0
|
||||
existed: int = 0
|
||||
skipped: int = 0
|
||||
rebind_bound: int = 0
|
||||
rebuild_users: int = 0
|
||||
cleanup_files: int = 0
|
||||
cleanup_failed: int = 0
|
||||
skip_reasons: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def skip(self, reason: str) -> None:
|
||||
self.skipped += 1
|
||||
self.skip_reasons[reason] = self.skip_reasons.get(reason, 0) + 1
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"scanned": self.scanned,
|
||||
"matched": self.matched,
|
||||
"inserted": self.inserted,
|
||||
"updated": self.updated,
|
||||
"existed": self.existed,
|
||||
"skipped": self.skipped,
|
||||
"rebind_bound": self.rebind_bound,
|
||||
"rebuild_users": self.rebuild_users,
|
||||
"cleanup_files": self.cleanup_files,
|
||||
"cleanup_failed": self.cleanup_failed,
|
||||
"skip_reasons": self.skip_reasons,
|
||||
}
|
||||
|
||||
|
||||
def _module_allowed(parsed: ParsedUploadPath, only_module: str | None) -> bool:
|
||||
if not only_module:
|
||||
return True
|
||||
if only_module == UploadResourceModuleEnum.COMMON.value:
|
||||
return parsed.module == UploadResourceModuleEnum.COMMON.value
|
||||
if only_module == UploadResourceModuleEnum.SHOT_REPLICATE.value:
|
||||
return parsed.module == UploadResourceModuleEnum.SHOT_REPLICATE.value
|
||||
return parsed.module == only_module
|
||||
|
||||
|
||||
def _collect_urls(value: Any) -> list[str]:
|
||||
urls: list[str] = []
|
||||
if value is None:
|
||||
return urls
|
||||
if isinstance(value, str):
|
||||
if value.startswith("/uploads/"):
|
||||
urls.append(value)
|
||||
return urls
|
||||
if isinstance(value, dict):
|
||||
for v in value.values():
|
||||
urls.extend(_collect_urls(v))
|
||||
return urls
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
urls.extend(_collect_urls(item))
|
||||
return urls
|
||||
|
||||
|
||||
async def _user_exists_map(db: AsyncSession, user_ids: Iterable[str]) -> set[str]:
|
||||
ids = [v for v in dict.fromkeys(user_ids) if v]
|
||||
if not ids:
|
||||
return set()
|
||||
result = await db.execute(select(User.id).where(User.id.in_(ids)))
|
||||
return set(result.scalars().all())
|
||||
|
||||
|
||||
async def _segment_map(db: AsyncSession, segment_ids: Iterable[str]) -> dict[str, ShotReplicateSegment]:
|
||||
ids = [v for v in dict.fromkeys(segment_ids) if v]
|
||||
if not ids:
|
||||
return {}
|
||||
result = await db.execute(select(ShotReplicateSegment).where(ShotReplicateSegment.id.in_(ids)))
|
||||
return {item.id: item for item in result.scalars().all()}
|
||||
|
||||
|
||||
async def _process_batch(db: AsyncSession, parsed_items: list[ParsedUploadPath], result: BackfillResult, options: BackfillOptions) -> None:
|
||||
if not parsed_items:
|
||||
return
|
||||
|
||||
segment_ids = [p.source_id for p in parsed_items if p.resource_type == "shot_segment" and p.source_id]
|
||||
segments = await _segment_map(db, segment_ids)
|
||||
|
||||
for parsed in parsed_items:
|
||||
if parsed.resource_type == "shot_segment":
|
||||
segment = segments.get(parsed.source_id or "")
|
||||
if not segment:
|
||||
result.skip("shot_segment_not_found")
|
||||
continue
|
||||
parsed.user_id = segment.user_id
|
||||
parsed.created_at = segment.created_at or parsed.created_at
|
||||
parsed.source_model = UploadResourceSourceModelEnum.SHOT_REPLICATE_SEGMENT.value
|
||||
parsed.source_id = segment.id
|
||||
|
||||
users = await _user_exists_map(db, [p.user_id for p in parsed_items if p.user_id])
|
||||
existing_result = await db.execute(select(UploadResource.storage_path).where(UploadResource.storage_path.in_([p.storage_path for p in parsed_items])))
|
||||
existing_paths = set(existing_result.scalars().all())
|
||||
|
||||
for parsed in parsed_items:
|
||||
if not parsed.user_id:
|
||||
result.skip("missing_user_id")
|
||||
continue
|
||||
if options.only_user_id and parsed.user_id != options.only_user_id:
|
||||
result.skip("user_filtered")
|
||||
continue
|
||||
if parsed.user_id not in users:
|
||||
result.skip("user_not_found")
|
||||
continue
|
||||
if parsed.storage_path in existing_paths:
|
||||
result.existed += 1
|
||||
if not options.dry_run:
|
||||
await record_external_upload_resource(
|
||||
db,
|
||||
user_id=parsed.user_id,
|
||||
module=parsed.module,
|
||||
resource_type=parsed.resource_type,
|
||||
resource_url=parsed.resource_url,
|
||||
storage_path=parsed.storage_path,
|
||||
file_size_bytes=parsed.file_size_bytes,
|
||||
file_name=parsed.file_name,
|
||||
source_model=parsed.source_model,
|
||||
source_id=parsed.source_id,
|
||||
bind_status=UploadResourceBindStatusEnum.BOUND.value if parsed.source_id else UploadResourceBindStatusEnum.PENDING.value,
|
||||
delete_policy=UploadResourceDeletePolicyEnum.MODULE_ONLY.value if parsed.source_id else UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
|
||||
created_by=UploadResourceCreatedByEnum.BACKFILL.value,
|
||||
created_at=parsed.created_at,
|
||||
metadata={"backfill": True},
|
||||
)
|
||||
result.updated += 1
|
||||
continue
|
||||
result.matched += 1
|
||||
if options.dry_run:
|
||||
continue
|
||||
await record_external_upload_resource(
|
||||
db,
|
||||
user_id=parsed.user_id,
|
||||
module=parsed.module,
|
||||
resource_type=parsed.resource_type,
|
||||
resource_url=parsed.resource_url,
|
||||
storage_path=parsed.storage_path,
|
||||
file_size_bytes=parsed.file_size_bytes,
|
||||
file_name=parsed.file_name,
|
||||
source_model=parsed.source_model,
|
||||
source_id=parsed.source_id,
|
||||
bind_status=UploadResourceBindStatusEnum.BOUND.value if parsed.source_id else UploadResourceBindStatusEnum.PENDING.value,
|
||||
delete_policy=UploadResourceDeletePolicyEnum.MODULE_ONLY.value if parsed.source_id else UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
|
||||
created_by=UploadResourceCreatedByEnum.BACKFILL.value,
|
||||
created_at=parsed.created_at,
|
||||
metadata={"backfill": True},
|
||||
)
|
||||
result.inserted += 1
|
||||
|
||||
|
||||
def _batched(items: Iterable[Path], batch_size: int):
|
||||
batch: list[Path] = []
|
||||
for item in items:
|
||||
batch.append(item)
|
||||
if len(batch) >= batch_size:
|
||||
yield batch
|
||||
batch = []
|
||||
if batch:
|
||||
yield batch
|
||||
|
||||
|
||||
async def _rebind_hot_opening(db: AsyncSession, *, only_user_id: str | None = None) -> int:
|
||||
stmt = select(ModuleGenerationProject.id, ModuleGenerationProject.user_id).where(
|
||||
ModuleGenerationProject.module == UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
|
||||
ModuleGenerationProject.deleted_at.is_(None),
|
||||
)
|
||||
if only_user_id:
|
||||
stmt = stmt.where(ModuleGenerationProject.user_id == only_user_id)
|
||||
projects = (await db.execute(stmt)).all()
|
||||
if not projects:
|
||||
return 0
|
||||
project_user = {pid: uid for pid, uid in projects}
|
||||
steps_result = await db.execute(
|
||||
select(ModuleGenerationStep.project_id, ModuleGenerationStep.input_json, ModuleGenerationStep.output_json).where(
|
||||
ModuleGenerationStep.project_id.in_(list(project_user.keys())),
|
||||
ModuleGenerationStep.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
grouped: dict[tuple[str, str], list[str]] = {}
|
||||
for project_id, input_json, output_json in steps_result.all():
|
||||
urls = _collect_urls(input_json) + _collect_urls(output_json)
|
||||
key = (project_user[project_id], project_id)
|
||||
grouped.setdefault(key, []).extend(urls)
|
||||
|
||||
bound = 0
|
||||
for (user_id, project_id), urls in grouped.items():
|
||||
stats = await bind_upload_resources(
|
||||
db,
|
||||
user_id=user_id,
|
||||
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
|
||||
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
|
||||
source_id=project_id,
|
||||
urls=urls,
|
||||
allow_common_migrate=True,
|
||||
)
|
||||
bound += stats.get("bound", 0)
|
||||
return bound
|
||||
|
||||
|
||||
async def _rebind_shot_replicate(db: AsyncSession, *, only_user_id: str | None = None) -> int:
|
||||
bound = 0
|
||||
task_stmt = select(ShotReplicateTaskSet.id, ShotReplicateTaskSet.user_id, ShotReplicateTaskSet.video_url).where(ShotReplicateTaskSet.deleted_at.is_(None))
|
||||
if only_user_id:
|
||||
task_stmt = task_stmt.where(ShotReplicateTaskSet.user_id == only_user_id)
|
||||
for task_set_id, user_id, video_url in (await db.execute(task_stmt)).all():
|
||||
stats = await bind_upload_resources(
|
||||
db,
|
||||
user_id=user_id,
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_TASK_SET.value,
|
||||
source_id=task_set_id,
|
||||
urls=[video_url],
|
||||
allow_common_migrate=True,
|
||||
)
|
||||
bound += stats.get("bound", 0)
|
||||
|
||||
project_stmt = select(ModuleGenerationProject.id, ModuleGenerationProject.user_id).where(
|
||||
ModuleGenerationProject.module == UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
ModuleGenerationProject.deleted_at.is_(None),
|
||||
)
|
||||
if only_user_id:
|
||||
project_stmt = project_stmt.where(ModuleGenerationProject.user_id == only_user_id)
|
||||
projects = (await db.execute(project_stmt)).all()
|
||||
if projects:
|
||||
project_user = {pid: uid for pid, uid in projects}
|
||||
steps_result = await db.execute(
|
||||
select(ModuleGenerationStep.project_id, ModuleGenerationStep.input_json, ModuleGenerationStep.output_json).where(
|
||||
ModuleGenerationStep.project_id.in_(list(project_user.keys())),
|
||||
ModuleGenerationStep.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
grouped: dict[tuple[str, str], list[str]] = {}
|
||||
for project_id, input_json, output_json in steps_result.all():
|
||||
key = (project_user[project_id], project_id)
|
||||
grouped.setdefault(key, []).extend(_collect_urls(input_json) + _collect_urls(output_json))
|
||||
for (user_id, project_id), urls in grouped.items():
|
||||
stats = await bind_upload_resources(
|
||||
db,
|
||||
user_id=user_id,
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
|
||||
source_id=project_id,
|
||||
urls=urls,
|
||||
allow_common_migrate=True,
|
||||
)
|
||||
bound += stats.get("bound", 0)
|
||||
return bound
|
||||
|
||||
|
||||
async def rebind_module_upload_resources(db: AsyncSession, *, only_module: str | None = None, only_user_id: str | None = None) -> int:
|
||||
total = 0
|
||||
if only_module in (None, UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value):
|
||||
total += await _rebind_hot_opening(db, only_user_id=only_user_id)
|
||||
if only_module in (None, UploadResourceModuleEnum.SHOT_REPLICATE.value):
|
||||
total += await _rebind_shot_replicate(db, only_user_id=only_user_id)
|
||||
return total
|
||||
|
||||
|
||||
async def run_upload_resource_backfill(db: AsyncSession, options: BackfillOptions) -> BackfillResult:
|
||||
result = BackfillResult()
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.BACKFILL_START.value,
|
||||
detail={"options": asdict(options)},
|
||||
)
|
||||
|
||||
if options.cleanup_pending_files:
|
||||
if not options.dry_run:
|
||||
cleanup_stats = await cleanup_pending_upload_resource_files(db, limit=options.cleanup_limit)
|
||||
result.cleanup_files = int(cleanup_stats.get("deleted", 0) or 0) + int(cleanup_stats.get("missing", 0) or 0)
|
||||
result.cleanup_failed = int(cleanup_stats.get("failed", 0) or 0)
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.BACKFILL_FINISHED.value,
|
||||
detail=result.to_dict(),
|
||||
)
|
||||
return result
|
||||
|
||||
for paths in _batched(iter_files(options.root), options.batch_size):
|
||||
parsed_items: list[ParsedUploadPath] = []
|
||||
for path in paths:
|
||||
result.scanned += 1
|
||||
parsed = parse_upload_path(path, include_legacy=options.include_legacy)
|
||||
if not parsed:
|
||||
result.skip("unparsed")
|
||||
continue
|
||||
if parsed.skip_reason:
|
||||
result.skip(parsed.skip_reason)
|
||||
continue
|
||||
if not _module_allowed(parsed, options.only_module):
|
||||
result.skip("module_filtered")
|
||||
continue
|
||||
parsed_items.append(parsed)
|
||||
await _process_batch(db, parsed_items, result, options)
|
||||
if not options.dry_run:
|
||||
await db.commit()
|
||||
|
||||
if options.rebind_modules:
|
||||
if options.dry_run:
|
||||
# rebind dry-run 不真实改库,只统计为 0,避免复杂模拟误导。
|
||||
result.rebind_bound = 0
|
||||
else:
|
||||
result.rebind_bound = await rebind_module_upload_resources(
|
||||
db,
|
||||
only_module=options.only_module if options.only_module in (UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value, UploadResourceModuleEnum.SHOT_REPLICATE.value) else None,
|
||||
only_user_id=options.only_user_id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
if options.rebuild_stats and not options.dry_run:
|
||||
stats = await rebuild_user_resource_stats(db, user_ids=[options.only_user_id] if options.only_user_id else None)
|
||||
result.rebuild_users = stats.get("users", 0)
|
||||
await db.commit()
|
||||
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.BACKFILL_FINISHED.value,
|
||||
detail=result.to_dict(),
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,197 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.upload_resource import (
|
||||
UploadResourceBindStatusEnum,
|
||||
UploadResourceCreatedByEnum,
|
||||
UploadResourceDeletePolicyEnum,
|
||||
UploadResourceEventEnum,
|
||||
UploadResourceFileDeleteStatusEnum,
|
||||
UploadResourceModuleEnum,
|
||||
UploadResourceSourceModelEnum,
|
||||
UploadResourceTypeEnum,
|
||||
)
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.models.shot_replicate_segment import ShotReplicateSegment
|
||||
from app.services.upload_resource.accounting_service import release_upload_resource_capacity
|
||||
from app.services.upload_resource.core_service import record_external_upload_resource
|
||||
from app.services.upload_resource.log_service import log_upload_resource_event
|
||||
from app.services.upload_resource.path_resolver import normalize_storage_path, upload_url_to_storage_path
|
||||
|
||||
|
||||
def _clean_ids(values: Iterable[str | None] | None) -> list[str]:
|
||||
if not values:
|
||||
return []
|
||||
return [str(v).strip() for v in dict.fromkeys(values) if v and str(v).strip()]
|
||||
|
||||
|
||||
def _clean_urls(values: Iterable[str | None] | None) -> list[str]:
|
||||
if not values:
|
||||
return []
|
||||
return [str(v).strip() for v in dict.fromkeys(values) if v and str(v).strip()]
|
||||
|
||||
|
||||
async def bind_upload_resources(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
module: str,
|
||||
source_model: str,
|
||||
source_id: str,
|
||||
resource_ids: Iterable[str | None] | None = None,
|
||||
urls: Iterable[str | None] | None = None,
|
||||
allow_common_migrate: bool = True,
|
||||
) -> dict[str, int]:
|
||||
ids = _clean_ids(resource_ids)
|
||||
url_values = _clean_urls(urls)
|
||||
storage_paths = [p for p in (upload_url_to_storage_path(url) for url in url_values) if p]
|
||||
|
||||
if not ids and not storage_paths:
|
||||
return {"matched": 0, "bound": 0, "skipped": 0, "conflict": 0}
|
||||
|
||||
conditions = []
|
||||
if ids:
|
||||
conditions.append(UploadResource.id.in_(ids))
|
||||
if storage_paths:
|
||||
conditions.append(UploadResource.storage_path.in_(storage_paths))
|
||||
|
||||
stmt = select(UploadResource).where(UploadResource.user_id == user_id, UploadResource.deleted_at.is_(None))
|
||||
if len(conditions) == 1:
|
||||
stmt = stmt.where(conditions[0])
|
||||
else:
|
||||
from sqlalchemy import or_
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
stmt = stmt.with_for_update()
|
||||
|
||||
result = await db.execute(stmt)
|
||||
resources = result.scalars().all()
|
||||
stats = {"matched": len(resources), "bound": 0, "skipped": 0, "conflict": 0}
|
||||
|
||||
for resource in resources:
|
||||
if resource.source_id == source_id and resource.source_model == source_model:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
if resource.source_id and resource.source_id != source_id:
|
||||
stats["conflict"] += 1
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.BIND_CONFLICT.value,
|
||||
module=module,
|
||||
user_id=user_id,
|
||||
resource_id=resource.id,
|
||||
source_model=source_model,
|
||||
source_id=source_id,
|
||||
event_status="warning",
|
||||
detail={
|
||||
"current_source_model": resource.source_model,
|
||||
"current_source_id": resource.source_id,
|
||||
"storage_path": resource.storage_path,
|
||||
},
|
||||
)
|
||||
continue
|
||||
if resource.module != module:
|
||||
if not (allow_common_migrate and resource.module == UploadResourceModuleEnum.COMMON.value):
|
||||
stats["conflict"] += 1
|
||||
continue
|
||||
resource.module = module
|
||||
resource.source_module = module
|
||||
resource.source_model = source_model
|
||||
resource.source_id = source_id
|
||||
resource.source_module = module
|
||||
resource.bind_status = UploadResourceBindStatusEnum.BOUND.value
|
||||
resource.delete_policy = UploadResourceDeletePolicyEnum.MODULE_ONLY.value
|
||||
stats["bound"] += 1
|
||||
|
||||
if stats["bound"]:
|
||||
await db.flush()
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.BIND_SUCCESS.value,
|
||||
module=module,
|
||||
user_id=user_id,
|
||||
source_model=source_model,
|
||||
source_id=source_id,
|
||||
detail=stats,
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def release_upload_resources_by_source(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
source_model: str,
|
||||
source_ids: Iterable[str],
|
||||
module: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ids = _clean_ids(source_ids)
|
||||
if not ids:
|
||||
return {"matched": 0, "released": 0, "released_size_bytes": 0, "released_resource_ids": []}
|
||||
stmt = select(UploadResource).where(
|
||||
UploadResource.source_model == source_model,
|
||||
UploadResource.source_id.in_(ids),
|
||||
UploadResource.deleted_at.is_(None),
|
||||
).with_for_update()
|
||||
if module:
|
||||
stmt = stmt.where(UploadResource.module == module)
|
||||
result = await db.execute(stmt)
|
||||
resources = result.scalars().all()
|
||||
now = datetime.now(timezone.utc)
|
||||
stats: dict[str, Any] = {
|
||||
"matched": len(resources),
|
||||
"released": 0,
|
||||
"released_size_bytes": 0,
|
||||
"released_resource_ids": [],
|
||||
}
|
||||
for resource in resources:
|
||||
resource_id = resource.id
|
||||
size = int(resource.file_size_bytes or 0)
|
||||
resource.deleted_at = now
|
||||
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value
|
||||
resource.file_delete_error = None
|
||||
if await release_upload_resource_capacity(db, resource, released_at=now):
|
||||
stats["released"] += 1
|
||||
stats["released_size_bytes"] += size
|
||||
stats["released_resource_ids"].append(resource_id)
|
||||
if resources:
|
||||
await db.flush()
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.DELETE_MARKED_PENDING.value,
|
||||
module=module,
|
||||
source_model=source_model,
|
||||
source_id=",".join(ids[:20]),
|
||||
detail={k: v for k, v in stats.items() if k != "released_resource_ids"} | {"released_resource_count": len(stats["released_resource_ids"])},
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def record_shot_segment_upload_resource(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
segment: ShotReplicateSegment,
|
||||
storage_path: str,
|
||||
resource_url: str,
|
||||
file_size_bytes: int,
|
||||
) -> UploadResource:
|
||||
return await record_external_upload_resource(
|
||||
db,
|
||||
user_id=segment.user_id,
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
resource_type=UploadResourceTypeEnum.SHOT_SEGMENT.value,
|
||||
resource_url=resource_url,
|
||||
storage_path=normalize_storage_path(storage_path),
|
||||
file_size_bytes=file_size_bytes,
|
||||
file_name=Path(storage_path).name,
|
||||
mime_type="video/mp4",
|
||||
duration_seconds=float(segment.duration_seconds or 0) if segment.duration_seconds else None,
|
||||
duration_source="business",
|
||||
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_SEGMENT.value,
|
||||
source_id=segment.id,
|
||||
bind_status=UploadResourceBindStatusEnum.BOUND.value,
|
||||
delete_policy=UploadResourceDeletePolicyEnum.MODULE_ONLY.value,
|
||||
created_by=UploadResourceCreatedByEnum.SPLIT_TASK.value,
|
||||
metadata={"task_set_id": segment.task_set_id, "segment_index": segment.segment_index},
|
||||
created_at=segment.created_at,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.resource_capacity import RESOURCE_CAPACITY_EXCEEDED_MESSAGE, ResourceCapacityErrorCodeEnum
|
||||
from app.models.user import User
|
||||
from app.services.resource_capacity_service import get_user_resource_capacity_usage
|
||||
from app.services.upload_resource.accounting_service import get_or_create_total_stat
|
||||
|
||||
|
||||
def is_admin_user(user: User | object | None) -> bool:
|
||||
if user is None:
|
||||
return False
|
||||
try:
|
||||
if getattr(user, "user_type", None) == "admin":
|
||||
return True
|
||||
if bool(getattr(user, "is_admin", False)):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
async def assert_upload_capacity_available(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: User,
|
||||
file_size_bytes: int,
|
||||
) -> None:
|
||||
"""上传容量拦截。
|
||||
|
||||
admin 用户不拦截,但统计仍会入账。普通用户锁定 total_stat 行后判断:
|
||||
active_size_bytes + 本次上传大小 <= 容量上限。
|
||||
"""
|
||||
if is_admin_user(user):
|
||||
return
|
||||
|
||||
total_stat = await get_or_create_total_stat(db, user.id, for_update=True)
|
||||
usage = await get_user_resource_capacity_usage(db, user.id)
|
||||
if not usage.enabled or usage.total_bytes is None:
|
||||
return
|
||||
|
||||
used = int(total_stat.active_size_bytes or 0)
|
||||
size = max(int(file_size_bytes or 0), 0)
|
||||
if used + size > int(usage.total_bytes or 0):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{RESOURCE_CAPACITY_EXCEEDED_MESSAGE},本次上传 {size} 字节,当前已用 {used} 字节,总容量 {usage.total_bytes} 字节",
|
||||
headers={"X-Error-Code": ResourceCapacityErrorCodeEnum.RESOURCE_CAPACITY_EXCEEDED.value},
|
||||
)
|
||||
@@ -0,0 +1,394 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.upload_resource import (
|
||||
UploadResourceBindStatusEnum,
|
||||
UploadResourceCreatedByEnum,
|
||||
UploadResourceDeletePolicyEnum,
|
||||
UploadResourceDurationSourceEnum,
|
||||
UploadResourceEventEnum,
|
||||
UploadResourceFileDeleteStatusEnum,
|
||||
UploadResourceModuleEnum,
|
||||
UploadResourceTypeEnum,
|
||||
)
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.models.user import User
|
||||
from app.services.upload_resource.accounting_service import apply_upload_resource_stat_delta, release_upload_resource_capacity, resource_month_from_datetime
|
||||
from app.services.upload_resource.capacity_service import assert_upload_capacity_available
|
||||
from app.services.upload_resource.log_service import log_upload_resource_event
|
||||
from app.services.upload_resource.path_resolver import build_upload_destination, normalize_storage_path, storage_path_to_upload_url, upload_url_to_storage_path
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
CHUNK_SIZE = 1024 * 1024
|
||||
IMAGE_MAX_BYTES = 10 * 1024 * 1024
|
||||
VIDEO_MAX_BYTES = 100 * 1024 * 1024
|
||||
AUDIO_DEFAULT_MAX_BYTES = 15 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UploadResourceResult:
|
||||
resource_id: str
|
||||
url: str
|
||||
filename: str
|
||||
resource_type: str
|
||||
module: str
|
||||
file_size_bytes: int
|
||||
duration_seconds: float | None = None
|
||||
|
||||
|
||||
def _json(data: Any) -> str | None:
|
||||
if data is None:
|
||||
return None
|
||||
if isinstance(data, str):
|
||||
return data
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _duration(value: float | int | str | None) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
return number if number > 0 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _validate_content_type(file: UploadFile, *, resource_type: str) -> None:
|
||||
content_type = file.content_type or ""
|
||||
if resource_type == UploadResourceTypeEnum.IMAGE.value and not content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="仅支持图片文件")
|
||||
if resource_type == UploadResourceTypeEnum.VIDEO.value and not content_type.startswith("video/"):
|
||||
raise HTTPException(status_code=400, detail="仅支持视频文件")
|
||||
if resource_type == UploadResourceTypeEnum.AUDIO.value and not content_type.startswith("audio/"):
|
||||
# 兼容部分浏览器/系统上传 mp3 时返回 application/octet-stream,最终仍由扩展名校验兜底。
|
||||
ext = Path(file.filename or "").suffix.lower().lstrip(".")
|
||||
if ext not in {"mp3", "wav", "m4a", "aac", "flac"}:
|
||||
raise HTTPException(status_code=400, detail="仅支持音频文件")
|
||||
|
||||
|
||||
def _max_bytes(resource_type: str, max_bytes: int | None = None) -> int:
|
||||
if max_bytes:
|
||||
return int(max_bytes)
|
||||
if resource_type == UploadResourceTypeEnum.IMAGE.value:
|
||||
return IMAGE_MAX_BYTES
|
||||
if resource_type == UploadResourceTypeEnum.VIDEO.value:
|
||||
return VIDEO_MAX_BYTES
|
||||
return AUDIO_DEFAULT_MAX_BYTES
|
||||
|
||||
|
||||
async def _save_to_temp(file: UploadFile, *, max_bytes: int) -> tuple[str, int]:
|
||||
fd, temp_path = tempfile.mkstemp(prefix="upload_resource_", suffix=".tmp")
|
||||
total = 0
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as out:
|
||||
while True:
|
||||
chunk = await file.read(CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(status_code=400, detail=f"文件大小不能超过 {max_bytes // 1024 // 1024}MB")
|
||||
out.write(chunk)
|
||||
return temp_path, total
|
||||
except Exception:
|
||||
try:
|
||||
os.close(fd)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
async def record_external_upload_resource(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
module: str,
|
||||
resource_type: str,
|
||||
resource_url: str,
|
||||
storage_path: str,
|
||||
file_size_bytes: int | None = None,
|
||||
file_name: str | None = None,
|
||||
mime_type: str | None = None,
|
||||
duration_seconds: float | None = None,
|
||||
duration_source: str | None = None,
|
||||
source_model: str | None = None,
|
||||
source_id: str | None = None,
|
||||
bind_status: str = UploadResourceBindStatusEnum.PENDING.value,
|
||||
delete_policy: str = UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
|
||||
created_by: str = UploadResourceCreatedByEnum.API.value,
|
||||
metadata: Any = None,
|
||||
created_at: datetime | None = None,
|
||||
) -> UploadResource:
|
||||
storage_path = normalize_storage_path(storage_path)
|
||||
size = int(file_size_bytes if file_size_bytes is not None else os.path.getsize(storage_path) if os.path.exists(storage_path) else 0)
|
||||
result = await db.execute(select(UploadResource).where(UploadResource.storage_path == storage_path).limit(1))
|
||||
existing = result.scalar_one_or_none()
|
||||
now = datetime.now(timezone.utc)
|
||||
if existing:
|
||||
old_size = int(existing.file_size_bytes or 0)
|
||||
existing.user_id = user_id
|
||||
existing.module = module
|
||||
existing.resource_type = resource_type
|
||||
existing.resource_url = resource_url
|
||||
existing.file_name = file_name or existing.file_name or Path(storage_path).name
|
||||
existing.file_ext = Path(storage_path).suffix.lower().lstrip(".")
|
||||
existing.mime_type = mime_type or existing.mime_type
|
||||
existing.file_size_bytes = size
|
||||
existing.duration_seconds = duration_seconds if duration_seconds is not None else existing.duration_seconds
|
||||
existing.duration_source = duration_source or existing.duration_source
|
||||
existing.source_model = source_model or existing.source_model
|
||||
existing.source_id = source_id or existing.source_id
|
||||
existing.source_module = module
|
||||
existing.bind_status = bind_status or existing.bind_status
|
||||
existing.delete_policy = delete_policy or existing.delete_policy
|
||||
existing.created_by = existing.created_by or created_by
|
||||
existing.metadata_json = _json(metadata) if metadata is not None else existing.metadata_json
|
||||
if created_at is not None:
|
||||
existing.created_at = created_at
|
||||
delta = size - old_size if existing.deleted_at is None and existing.capacity_released_at is None else 0
|
||||
if delta:
|
||||
await apply_upload_resource_stat_delta(
|
||||
db,
|
||||
user_id=user_id,
|
||||
stat_month=resource_month_from_datetime(existing.created_at or now),
|
||||
resource_type=resource_type,
|
||||
active_size_delta=delta,
|
||||
upload_size_delta=delta,
|
||||
)
|
||||
await db.flush()
|
||||
return existing
|
||||
|
||||
resource = UploadResource(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
module=module,
|
||||
resource_type=resource_type,
|
||||
resource_url=resource_url,
|
||||
storage_path=storage_path,
|
||||
file_name=file_name or Path(storage_path).name,
|
||||
file_ext=Path(storage_path).suffix.lower().lstrip("."),
|
||||
mime_type=mime_type,
|
||||
file_size_bytes=size,
|
||||
duration_seconds=duration_seconds,
|
||||
duration_source=duration_source,
|
||||
source_model=source_model,
|
||||
source_id=source_id,
|
||||
source_module=module,
|
||||
bind_status=bind_status,
|
||||
delete_policy=delete_policy,
|
||||
created_by=created_by,
|
||||
metadata_json=_json(metadata),
|
||||
)
|
||||
if created_at is not None:
|
||||
resource.created_at = created_at
|
||||
db.add(resource)
|
||||
await db.flush()
|
||||
await apply_upload_resource_stat_delta(
|
||||
db,
|
||||
user_id=user_id,
|
||||
stat_month=resource_month_from_datetime(created_at or now),
|
||||
resource_type=resource_type,
|
||||
active_size_delta=size,
|
||||
active_count_delta=1,
|
||||
upload_size_delta=size,
|
||||
upload_count_delta=1,
|
||||
)
|
||||
await db.flush()
|
||||
return resource
|
||||
|
||||
|
||||
async def upload_reference_file(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
file: UploadFile,
|
||||
current_user: User,
|
||||
module: str = UploadResourceModuleEnum.COMMON.value,
|
||||
resource_type: str,
|
||||
gen_type: str = "video",
|
||||
duration_seconds: float | None = None,
|
||||
max_bytes: int | None = None,
|
||||
) -> UploadResourceResult:
|
||||
_validate_content_type(file, resource_type=resource_type)
|
||||
max_size = _max_bytes(resource_type, max_bytes)
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_START.value,
|
||||
module=module,
|
||||
user_id=current_user.id,
|
||||
detail={"filename": file.filename, "content_type": file.content_type, "resource_type": resource_type},
|
||||
)
|
||||
|
||||
temp_path: str | None = None
|
||||
final_path: Path | None = None
|
||||
try:
|
||||
temp_path, size = await _save_to_temp(file, max_bytes=max_size)
|
||||
await assert_upload_capacity_available(db, user=current_user, file_size_bytes=size)
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_CAPACITY_CHECKED.value,
|
||||
module=module,
|
||||
user_id=current_user.id,
|
||||
detail={"file_size_bytes": size},
|
||||
)
|
||||
|
||||
final_path, url, safe_name = build_upload_destination(
|
||||
module=module,
|
||||
resource_type=resource_type,
|
||||
user_id=current_user.id,
|
||||
original_filename=file.filename,
|
||||
gen_type=gen_type,
|
||||
)
|
||||
final_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(temp_path, final_path)
|
||||
temp_path = None
|
||||
|
||||
resource = await record_external_upload_resource(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
module=module,
|
||||
resource_type=resource_type,
|
||||
resource_url=url,
|
||||
storage_path=str(final_path),
|
||||
file_size_bytes=size,
|
||||
file_name=safe_name,
|
||||
mime_type=file.content_type,
|
||||
duration_seconds=_duration(duration_seconds),
|
||||
duration_source=UploadResourceDurationSourceEnum.CLIENT.value if _duration(duration_seconds) is not None else None,
|
||||
bind_status=UploadResourceBindStatusEnum.PENDING.value,
|
||||
delete_policy=UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
|
||||
created_by=UploadResourceCreatedByEnum.API.value,
|
||||
metadata={"original_filename": file.filename, "client_duration_seconds": duration_seconds},
|
||||
)
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_DB_RECORDED.value,
|
||||
module=module,
|
||||
user_id=current_user.id,
|
||||
resource_id=resource.id,
|
||||
detail={"url": url, "storage_path": str(final_path), "file_size_bytes": size},
|
||||
)
|
||||
return UploadResourceResult(
|
||||
resource_id=resource.id,
|
||||
url=url,
|
||||
filename=file.filename or safe_name,
|
||||
resource_type=resource_type,
|
||||
module=module,
|
||||
file_size_bytes=size,
|
||||
duration_seconds=resource.duration_seconds,
|
||||
)
|
||||
except Exception as exc:
|
||||
if temp_path:
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
if final_path and final_path.exists():
|
||||
try:
|
||||
final_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_FAILED.value,
|
||||
module=module,
|
||||
user_id=current_user.id,
|
||||
detail={"filename": file.filename, "resource_type": resource_type},
|
||||
exc=exc,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def delete_unbound_upload_resource(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: User,
|
||||
url: str,
|
||||
) -> dict[str, Any]:
|
||||
storage_path = upload_url_to_storage_path(url)
|
||||
if not storage_path:
|
||||
raise HTTPException(status_code=400, detail="无效的文件路径")
|
||||
|
||||
result = await db.execute(
|
||||
select(UploadResource)
|
||||
.where(
|
||||
UploadResource.storage_path == storage_path,
|
||||
UploadResource.user_id == user.id,
|
||||
UploadResource.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
resource = result.scalar_one_or_none()
|
||||
if not resource:
|
||||
# 兼容历史未补录文件:仍然要求 URL 中包含用户 ID。
|
||||
# 真实文件删除必须等 API 主事务 commit 成功后由 file_cleanup_service 执行。
|
||||
if user.id not in url:
|
||||
raise HTTPException(status_code=403, detail="无权删除此文件")
|
||||
return {
|
||||
"message": "ok",
|
||||
"resource_id": None,
|
||||
"deleted": True,
|
||||
"capacity_released": False,
|
||||
"released_size_bytes": 0,
|
||||
"_pending_physical_delete_resource_ids": [],
|
||||
"_legacy_pending_delete_paths": [storage_path],
|
||||
}
|
||||
|
||||
if resource.bind_status == UploadResourceBindStatusEnum.BOUND.value or resource.source_id or resource.delete_policy != UploadResourceDeletePolicyEnum.USER_DELETABLE.value:
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.DELETE_REJECTED_BOUND.value,
|
||||
module=resource.module,
|
||||
user_id=user.id,
|
||||
resource_id=resource.id,
|
||||
source_model=resource.source_model,
|
||||
source_id=resource.source_id,
|
||||
event_status="rejected",
|
||||
message="已绑定模块业务记录的上传资源不允许单独删除",
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="该文件已被模块任务使用,不能单独删除,请删除对应模块记录后自动释放空间。")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
resource_id = resource.id
|
||||
storage_path_snapshot = resource.storage_path
|
||||
file_size_snapshot = int(resource.file_size_bytes or 0)
|
||||
module_snapshot = resource.module
|
||||
resource.deleted_at = now
|
||||
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value
|
||||
resource.file_delete_error = None
|
||||
released = await release_upload_resource_capacity(db, resource, released_at=now)
|
||||
await db.flush()
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.DELETE_MARKED_PENDING.value,
|
||||
module=module_snapshot,
|
||||
user_id=user.id,
|
||||
resource_id=resource_id,
|
||||
detail={
|
||||
"url": url,
|
||||
"storage_path": storage_path_snapshot,
|
||||
"file_size_bytes": file_size_snapshot,
|
||||
"capacity_released": released,
|
||||
"file_delete_status": UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"message": "ok",
|
||||
"resource_id": resource_id,
|
||||
"deleted": True,
|
||||
"capacity_released": released,
|
||||
"released_size_bytes": file_size_snapshot if released else 0,
|
||||
"_pending_physical_delete_resource_ids": [resource_id],
|
||||
"_legacy_pending_delete_paths": [],
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceFileDeleteStatusEnum
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.models.user import User
|
||||
from app.schemas.upload_resource import (
|
||||
UploadResourceCleanupOut,
|
||||
UploadResourceHistoryBatchDeleteOut,
|
||||
)
|
||||
from app.services.upload_resource.accounting_service import release_upload_resource_capacity
|
||||
from app.services.upload_resource.file_cleanup_service import cleanup_upload_resource_files_after_commit
|
||||
from app.services.upload_resource.history_service import assert_upload_resource_history_ids_visible
|
||||
from app.services.upload_resource.log_service import log_upload_resource_event
|
||||
|
||||
MAX_UPLOAD_RESOURCE_BATCH_DELETE_COUNT = 30
|
||||
|
||||
|
||||
def normalize_upload_resource_ids(values: Iterable[str | None]) -> list[str]:
|
||||
ids = [str(value).strip() for value in values if str(value or "").strip()]
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="resource_ids 不能为空")
|
||||
if len(ids) > MAX_UPLOAD_RESOURCE_BATCH_DELETE_COUNT:
|
||||
raise HTTPException(status_code=400, detail=f"单次最多删除 {MAX_UPLOAD_RESOURCE_BATCH_DELETE_COUNT} 条上传素材")
|
||||
if len(ids) != len(set(ids)):
|
||||
raise HTTPException(status_code=400, detail="resource_ids 不允许重复")
|
||||
return ids
|
||||
|
||||
|
||||
def _cleanup_out(stats: dict[str, int] | None) -> UploadResourceCleanupOut:
|
||||
stats = stats or {}
|
||||
return UploadResourceCleanupOut(
|
||||
matched=int(stats.get("matched", 0) or 0),
|
||||
deleted=int(stats.get("deleted", 0) or 0),
|
||||
missing=int(stats.get("missing", 0) or 0),
|
||||
failed=int(stats.get("failed", 0) or 0),
|
||||
legacy_deleted=int(stats.get("legacy_deleted", 0) or 0),
|
||||
legacy_missing=int(stats.get("legacy_missing", 0) or 0),
|
||||
legacy_failed=int(stats.get("legacy_failed", 0) or 0),
|
||||
)
|
||||
|
||||
|
||||
async def mark_upload_resource_history_deleted(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
resource_ids: Iterable[str | None],
|
||||
) -> UploadResourceHistoryBatchDeleteOut:
|
||||
"""主事务内软删上传历史素材并释放容量,不删除真实文件。"""
|
||||
|
||||
ids = normalize_upload_resource_ids(resource_ids)
|
||||
user_id = str(current_user.id)
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.DELETE_BATCH_START.value,
|
||||
user_id=user_id,
|
||||
detail={"requested_ids": ids, "requested_count": len(ids)},
|
||||
)
|
||||
|
||||
resources = await assert_upload_resource_history_ids_visible(db, user_id=user_id, resource_ids=ids)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# 先抽 primitive 快照,commit/rollback 后日志不再碰 ORM,规避懒加载失效风险。
|
||||
snapshots = [
|
||||
{
|
||||
"id": resource.id,
|
||||
"user_id": resource.user_id,
|
||||
"module": resource.module,
|
||||
"resource_type": resource.resource_type,
|
||||
"resource_url": resource.resource_url,
|
||||
"storage_path": resource.storage_path,
|
||||
"file_size_bytes": int(resource.file_size_bytes or 0),
|
||||
}
|
||||
for resource in resources
|
||||
]
|
||||
|
||||
released_size = 0
|
||||
released_ids: list[str] = []
|
||||
for resource in resources:
|
||||
resource.deleted_at = now
|
||||
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value
|
||||
resource.file_delete_error = None
|
||||
released = await release_upload_resource_capacity(db, resource, released_at=now)
|
||||
if released:
|
||||
released_size += int(resource.file_size_bytes or 0)
|
||||
released_ids.append(resource.id)
|
||||
|
||||
await db.flush()
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.DELETE_BATCH_MARKED_PENDING.value,
|
||||
user_id=user_id,
|
||||
detail={
|
||||
"requested_ids": ids,
|
||||
"deleted_ids": ids,
|
||||
"released_ids": released_ids,
|
||||
"released_size_bytes": released_size,
|
||||
"snapshots": snapshots,
|
||||
"file_delete_status": UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value,
|
||||
},
|
||||
)
|
||||
|
||||
return UploadResourceHistoryBatchDeleteOut(
|
||||
message="删除成功",
|
||||
requested_count=len(ids),
|
||||
deleted_count=len(ids),
|
||||
requested_ids=ids,
|
||||
deleted_ids=ids,
|
||||
released_size_bytes=released_size,
|
||||
cleanup=UploadResourceCleanupOut(),
|
||||
)
|
||||
|
||||
|
||||
async def cleanup_upload_resource_history_files(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
result: UploadResourceHistoryBatchDeleteOut,
|
||||
current_user: User,
|
||||
) -> UploadResourceHistoryBatchDeleteOut:
|
||||
"""主事务 commit 成功后清理真实文件,失败不回滚主删除。"""
|
||||
|
||||
stats = await cleanup_upload_resource_files_after_commit(db, resource_ids=result.deleted_ids)
|
||||
result.cleanup = _cleanup_out(stats)
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.DELETE_BATCH_CLEANUP_SUCCESS.value,
|
||||
user_id=str(current_user.id),
|
||||
detail={
|
||||
"requested_ids": result.requested_ids,
|
||||
"deleted_ids": result.deleted_ids,
|
||||
"cleanup": result.cleanup.model_dump(),
|
||||
},
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,204 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceFileDeleteStatusEnum
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.services.upload_resource.log_service import log_upload_resource_event, log_upload_resource_exception
|
||||
from app.services.upload_resource.path_resolver import normalize_storage_path
|
||||
|
||||
|
||||
def _clean_ids(values: Iterable[str | None] | None) -> list[str]:
|
||||
if not values:
|
||||
return []
|
||||
return [str(v).strip() for v in dict.fromkeys(values) if v and str(v).strip()]
|
||||
|
||||
|
||||
def _clean_paths(values: Iterable[str | None] | None) -> list[str]:
|
||||
if not values:
|
||||
return []
|
||||
cleaned: list[str] = []
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
cleaned.append(normalize_storage_path(value))
|
||||
except Exception:
|
||||
cleaned.append(str(value))
|
||||
return list(dict.fromkeys(cleaned))
|
||||
|
||||
|
||||
def _short_error(exc: BaseException) -> str:
|
||||
text = str(exc) or exc.__class__.__name__
|
||||
return text[:2000]
|
||||
|
||||
|
||||
async def cleanup_upload_resource_files_after_commit(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
resource_ids: Iterable[str | None] | None = None,
|
||||
legacy_paths: Iterable[str | None] | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""API 主事务 commit 成功后清理 UploadResource 真实文件。
|
||||
|
||||
这里不负责业务删除事务,不做 rollback;调用方如需持久化清理状态,
|
||||
应在本函数返回后由 API 层再次 commit。
|
||||
"""
|
||||
ids = _clean_ids(resource_ids)
|
||||
paths = _clean_paths(legacy_paths)
|
||||
stats = {
|
||||
"matched": 0,
|
||||
"deleted": 0,
|
||||
"missing": 0,
|
||||
"failed": 0,
|
||||
"legacy_deleted": 0,
|
||||
"legacy_missing": 0,
|
||||
"legacy_failed": 0,
|
||||
}
|
||||
|
||||
if ids:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UploadResource)
|
||||
.where(
|
||||
UploadResource.id.in_(ids),
|
||||
UploadResource.deleted_at.is_not(None),
|
||||
UploadResource.physical_deleted_at.is_(None),
|
||||
UploadResource.file_delete_status.in_([
|
||||
UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value,
|
||||
UploadResourceFileDeleteStatusEnum.DELETE_FAILED.value,
|
||||
]),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
resources = list(result.scalars().all())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_CLEANUP_BATCH_FAILED.value,
|
||||
message="UploadResource 真实文件清理批量查询失败",
|
||||
resource_ids=ids,
|
||||
detail={"stage": "query_resources", "resource_ids_count": len(ids)},
|
||||
exc=exc,
|
||||
)
|
||||
raise
|
||||
|
||||
stats["matched"] = len(resources)
|
||||
for resource in resources:
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
path = Path(resource.storage_path)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.DELETED.value
|
||||
stats["deleted"] += 1
|
||||
event = UploadResourceEventEnum.DELETE_PHYSICAL_SUCCESS.value
|
||||
else:
|
||||
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.MISSING.value
|
||||
stats["missing"] += 1
|
||||
event = UploadResourceEventEnum.DELETE_PHYSICAL_MISSING.value
|
||||
resource.physical_deleted_at = now
|
||||
resource.file_delete_error = None
|
||||
log_upload_resource_event(
|
||||
event_type=event,
|
||||
module=resource.module,
|
||||
user_id=resource.user_id,
|
||||
resource_id=resource.id,
|
||||
source_model=resource.source_model,
|
||||
source_id=resource.source_id,
|
||||
detail={"storage_path": resource.storage_path, "file_delete_status": resource.file_delete_status},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.DELETE_FAILED.value
|
||||
resource.file_delete_error = _short_error(exc)
|
||||
stats["failed"] += 1
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.DELETE_PHYSICAL_FAILED.value,
|
||||
module=resource.module,
|
||||
user_id=resource.user_id,
|
||||
resource_id=resource.id,
|
||||
source_model=resource.source_model,
|
||||
source_id=resource.source_id,
|
||||
detail={"storage_path": resource.storage_path},
|
||||
exc=exc,
|
||||
)
|
||||
if resources:
|
||||
try:
|
||||
await db.flush()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_CLEANUP_BATCH_FAILED.value,
|
||||
message="UploadResource 真实文件清理状态 flush 失败",
|
||||
resource_ids=[resource.id for resource in resources],
|
||||
detail={"stage": "flush_cleanup_status"},
|
||||
exc=exc,
|
||||
)
|
||||
raise
|
||||
|
||||
for raw_path in paths:
|
||||
try:
|
||||
path = Path(raw_path)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
stats["legacy_deleted"] += 1
|
||||
else:
|
||||
stats["legacy_missing"] += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
stats["legacy_failed"] += 1
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.DELETE_PHYSICAL_FAILED.value,
|
||||
detail={"legacy_path": raw_path},
|
||||
exc=exc,
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def cleanup_pending_upload_resource_files(db: AsyncSession, *, limit: int = 500) -> dict[str, int]:
|
||||
limit = max(1, int(limit or 500))
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.CLEANUP_PENDING_START.value,
|
||||
detail={"limit": limit},
|
||||
)
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UploadResource.id)
|
||||
.where(
|
||||
UploadResource.deleted_at.is_not(None),
|
||||
UploadResource.physical_deleted_at.is_(None),
|
||||
UploadResource.file_delete_status.in_([
|
||||
UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value,
|
||||
UploadResourceFileDeleteStatusEnum.DELETE_FAILED.value,
|
||||
]),
|
||||
)
|
||||
.order_by(UploadResource.updated_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
ids = list(result.scalars().all())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_CLEANUP_BATCH_FAILED.value,
|
||||
message="UploadResource pending 清理查询失败",
|
||||
detail={"stage": "query_pending_cleanup", "limit": limit},
|
||||
exc=exc,
|
||||
)
|
||||
raise
|
||||
try:
|
||||
stats = await cleanup_upload_resource_files_after_commit(db, resource_ids=ids)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_CLEANUP_BATCH_FAILED.value,
|
||||
message="UploadResource pending 真实文件补偿清理失败",
|
||||
resource_ids=ids,
|
||||
detail={"stage": "cleanup_pending", "limit": limit},
|
||||
exc=exc,
|
||||
)
|
||||
raise
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.CLEANUP_PENDING_FINISHED.value,
|
||||
detail={"ids": len(ids), **stats},
|
||||
)
|
||||
return stats
|
||||
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Iterable
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, desc, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.upload_resource import (
|
||||
UPLOAD_RESOURCE_MODULE_LABELS,
|
||||
UPLOAD_RESOURCE_TYPE_LABELS,
|
||||
UploadResourceBindStatusEnum,
|
||||
UploadResourceDeletePolicyEnum,
|
||||
UploadResourceEventEnum,
|
||||
UploadResourceTypeEnum,
|
||||
)
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.schemas.upload_resource import (
|
||||
UPLOAD_RESOURCE_HISTORY_ALLOWED_RESOURCE_TYPES,
|
||||
UploadResourceHistoryDayGroupOut,
|
||||
UploadResourceHistoryDayItemsOut,
|
||||
UploadResourceHistoryGroupedOut,
|
||||
UploadResourceHistoryItemOut,
|
||||
UploadResourceMediaReferenceOut,
|
||||
)
|
||||
from app.services.upload_resource.log_service import log_upload_resource_event
|
||||
|
||||
HISTORY_SOURCE = "upload_resource"
|
||||
HISTORY_SOURCE_LABEL = "历史上传素材"
|
||||
DEFAULT_GROUP_ITEMS_LIMIT = 10
|
||||
MAX_GROUP_PAGE_SIZE = 10
|
||||
MAX_DAY_PAGE_SIZE = 100
|
||||
|
||||
|
||||
def _normalize_resource_type(resource_type: str | None) -> str | None:
|
||||
if resource_type is None or str(resource_type).strip() == "":
|
||||
return None
|
||||
value = str(resource_type).strip()
|
||||
if value not in UPLOAD_RESOURCE_HISTORY_ALLOWED_RESOURCE_TYPES:
|
||||
raise HTTPException(status_code=400, detail="resource_type 仅支持 image、video、audio")
|
||||
return value
|
||||
|
||||
|
||||
def _page(value: int, *, default: int = 1) -> int:
|
||||
return max(int(value or default), 1)
|
||||
|
||||
|
||||
def _page_size(value: int, *, default: int, max_value: int) -> int:
|
||||
return min(max(int(value or default), 1), max_value)
|
||||
|
||||
|
||||
def _date_text(value: object) -> str:
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
return str(value)[:10]
|
||||
|
||||
|
||||
def _display_url(resource: UploadResource) -> str:
|
||||
return str(resource.resource_url or "")
|
||||
|
||||
|
||||
def _resource_type(resource: UploadResource) -> str:
|
||||
return str(resource.resource_type or "")
|
||||
|
||||
|
||||
def _item_to_out(resource: UploadResource) -> UploadResourceHistoryItemOut:
|
||||
resource_type = _resource_type(resource)
|
||||
display_url = _display_url(resource)
|
||||
file_name = resource.file_name or display_url.rsplit("/", 1)[-1] or resource.id
|
||||
duration = float(resource.duration_seconds) if resource.duration_seconds is not None else None
|
||||
|
||||
media_reference = UploadResourceMediaReferenceOut(
|
||||
name=file_name,
|
||||
type=resource_type, # type: ignore[arg-type]
|
||||
url=display_url,
|
||||
label="",
|
||||
duration=duration if resource_type in {UploadResourceTypeEnum.VIDEO.value, UploadResourceTypeEnum.AUDIO.value} else None,
|
||||
source=HISTORY_SOURCE,
|
||||
upload_resource_id=resource.id,
|
||||
)
|
||||
|
||||
return UploadResourceHistoryItemOut(
|
||||
id=resource.id,
|
||||
source_type=HISTORY_SOURCE,
|
||||
history_source=HISTORY_SOURCE,
|
||||
history_source_label=HISTORY_SOURCE_LABEL,
|
||||
module=resource.module,
|
||||
module_label=UPLOAD_RESOURCE_MODULE_LABELS.get(resource.module, resource.module),
|
||||
resource_type=resource_type, # type: ignore[arg-type]
|
||||
resource_type_label=UPLOAD_RESOURCE_TYPE_LABELS.get(resource_type, resource_type),
|
||||
resource_url=display_url,
|
||||
display_url=display_url,
|
||||
preview_url=display_url,
|
||||
image_url=display_url if resource_type == UploadResourceTypeEnum.IMAGE.value else None,
|
||||
video_url=display_url if resource_type == UploadResourceTypeEnum.VIDEO.value else None,
|
||||
audio_url=display_url if resource_type == UploadResourceTypeEnum.AUDIO.value else None,
|
||||
file_name=resource.file_name,
|
||||
file_ext=resource.file_ext,
|
||||
mime_type=resource.mime_type,
|
||||
file_size_bytes=int(resource.file_size_bytes or 0),
|
||||
duration_seconds=duration,
|
||||
width=int(resource.width) if resource.width is not None else None,
|
||||
height=int(resource.height) if resource.height is not None else None,
|
||||
bind_status=resource.bind_status,
|
||||
delete_policy=resource.delete_policy,
|
||||
deletable=True,
|
||||
media_reference=media_reference,
|
||||
created_at=resource.created_at,
|
||||
updated_at=resource.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _base_filters(*, user_id: str, resource_type: str | None = None, keyword: str | None = None) -> list:
|
||||
filters = [
|
||||
UploadResource.user_id == user_id,
|
||||
UploadResource.deleted_at.is_(None),
|
||||
UploadResource.source_model.is_(None),
|
||||
UploadResource.source_id.is_(None),
|
||||
UploadResource.bind_status == UploadResourceBindStatusEnum.PENDING.value,
|
||||
UploadResource.delete_policy == UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
|
||||
UploadResource.resource_type.in_(list(UPLOAD_RESOURCE_HISTORY_ALLOWED_RESOURCE_TYPES)),
|
||||
]
|
||||
if resource_type:
|
||||
filters.append(UploadResource.resource_type == resource_type)
|
||||
if keyword and keyword.strip():
|
||||
pattern = f"%{keyword.strip()}%"
|
||||
filters.append(
|
||||
or_(
|
||||
UploadResource.file_name.ilike(pattern),
|
||||
UploadResource.resource_url.ilike(pattern),
|
||||
)
|
||||
)
|
||||
return filters
|
||||
|
||||
|
||||
async def list_upload_resource_history_grouped_days(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
resource_type: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: str | None = None,
|
||||
) -> UploadResourceHistoryGroupedOut:
|
||||
"""按上传日期分组查询可展示/可复用/可删除的 UploadResource。"""
|
||||
|
||||
resource_type = _normalize_resource_type(resource_type)
|
||||
page = _page(page)
|
||||
page_size = _page_size(page_size, default=10, max_value=MAX_GROUP_PAGE_SIZE)
|
||||
filters = _base_filters(user_id=user_id, resource_type=resource_type, keyword=keyword)
|
||||
date_expr = func.date(UploadResource.created_at).label("generated_date")
|
||||
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.HISTORY_LIST_START.value,
|
||||
user_id=user_id,
|
||||
detail={"resource_type": resource_type, "page": page, "page_size": page_size, "keyword": keyword},
|
||||
)
|
||||
|
||||
total_days_stmt = select(func.count()).select_from(
|
||||
select(date_expr).where(and_(*filters)).group_by(date_expr).subquery()
|
||||
)
|
||||
total_days = int((await db.execute(total_days_stmt)).scalar_one() or 0)
|
||||
|
||||
group_stmt = (
|
||||
select(date_expr, func.count(UploadResource.id).label("total"))
|
||||
.where(and_(*filters))
|
||||
.group_by(date_expr)
|
||||
.order_by(desc(date_expr))
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
group_rows = (await db.execute(group_stmt)).mappings().all()
|
||||
date_values = [row["generated_date"] for row in group_rows]
|
||||
date_texts = [_date_text(value) for value in date_values]
|
||||
|
||||
items_by_date: dict[str, list[UploadResourceHistoryItemOut]] = {date_text: [] for date_text in date_texts}
|
||||
if date_texts:
|
||||
rn = func.row_number().over(
|
||||
partition_by=func.date(UploadResource.created_at),
|
||||
order_by=(UploadResource.created_at.desc(), UploadResource.id.desc()),
|
||||
).label("rn")
|
||||
id_subq = (
|
||||
select(
|
||||
UploadResource.id.label("id"),
|
||||
func.date(UploadResource.created_at).label("generated_date"),
|
||||
rn,
|
||||
)
|
||||
.where(and_(*filters), func.date(UploadResource.created_at).in_(date_values))
|
||||
.subquery()
|
||||
)
|
||||
item_stmt = (
|
||||
select(UploadResource)
|
||||
.join(id_subq, UploadResource.id == id_subq.c.id)
|
||||
.where(id_subq.c.rn <= DEFAULT_GROUP_ITEMS_LIMIT)
|
||||
.order_by(id_subq.c.generated_date.desc(), UploadResource.created_at.desc(), UploadResource.id.desc())
|
||||
)
|
||||
resources = list((await db.execute(item_stmt)).scalars().all())
|
||||
for resource in resources:
|
||||
items_by_date.setdefault(_date_text(resource.created_at.date()), []).append(_item_to_out(resource))
|
||||
|
||||
groups = [
|
||||
UploadResourceHistoryDayGroupOut(
|
||||
generated_date=_date_text(row["generated_date"]),
|
||||
total=int(row["total"] or 0),
|
||||
page=1,
|
||||
items=items_by_date.get(_date_text(row["generated_date"]), []),
|
||||
)
|
||||
for row in group_rows
|
||||
]
|
||||
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.HISTORY_LIST_SUCCESS.value,
|
||||
user_id=user_id,
|
||||
detail={"resource_type": resource_type, "page": page, "page_size": page_size, "groups": len(groups)},
|
||||
)
|
||||
return UploadResourceHistoryGroupedOut(total_days=total_days, page=page, page_size=page_size, groups=groups)
|
||||
|
||||
|
||||
async def list_upload_resource_history_day_items(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
generated_date: str,
|
||||
resource_type: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
keyword: str | None = None,
|
||||
) -> UploadResourceHistoryDayItemsOut:
|
||||
"""查询指定上传日期下的 UploadResource 历史素材。"""
|
||||
|
||||
try:
|
||||
target_date = date.fromisoformat(str(generated_date))
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="generated_date 必须是 YYYY-MM-DD 格式")
|
||||
|
||||
resource_type = _normalize_resource_type(resource_type)
|
||||
page = _page(page)
|
||||
page_size = _page_size(page_size, default=20, max_value=MAX_DAY_PAGE_SIZE)
|
||||
filters = _base_filters(user_id=user_id, resource_type=resource_type, keyword=keyword)
|
||||
filters.append(func.date(UploadResource.created_at) == target_date)
|
||||
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.HISTORY_DAY_LIST_START.value,
|
||||
user_id=user_id,
|
||||
detail={"generated_date": generated_date, "resource_type": resource_type, "page": page, "page_size": page_size, "keyword": keyword},
|
||||
)
|
||||
|
||||
total_stmt = select(func.count(UploadResource.id)).where(and_(*filters))
|
||||
total = int((await db.execute(total_stmt)).scalar_one() or 0)
|
||||
|
||||
item_stmt = (
|
||||
select(UploadResource)
|
||||
.where(and_(*filters))
|
||||
.order_by(UploadResource.created_at.desc(), UploadResource.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
resources = list((await db.execute(item_stmt)).scalars().all())
|
||||
items = [_item_to_out(resource) for resource in resources]
|
||||
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.HISTORY_DAY_LIST_SUCCESS.value,
|
||||
user_id=user_id,
|
||||
detail={"generated_date": generated_date, "resource_type": resource_type, "page": page, "page_size": page_size, "total": total},
|
||||
)
|
||||
return UploadResourceHistoryDayItemsOut(
|
||||
generated_date=target_date.isoformat(),
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
async def assert_upload_resource_history_ids_visible(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
resource_ids: Iterable[str],
|
||||
) -> list[UploadResource]:
|
||||
ids = [str(value).strip() for value in resource_ids if str(value or "").strip()]
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="resource_ids 不能为空")
|
||||
stmt = (
|
||||
select(UploadResource)
|
||||
.where(
|
||||
UploadResource.id.in_(ids),
|
||||
*_base_filters(user_id=user_id),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
resources = list((await db.execute(stmt)).scalars().all())
|
||||
found = {resource.id for resource in resources}
|
||||
missing = [resource_id for resource_id in ids if resource_id not in found]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"message": "上传素材不存在、已删除、已绑定模块业务记录或无权操作",
|
||||
"missing_ids": missing,
|
||||
"missing_count": len(missing),
|
||||
},
|
||||
)
|
||||
return resources
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.operation_log_service import build_exception_detail, log_operation_event
|
||||
|
||||
DOMAIN = "upload_resource"
|
||||
logger = logging.getLogger("videogen.upload_resource")
|
||||
|
||||
|
||||
def _exception_payload(exc: BaseException | None, detail: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
payload = dict(detail or {})
|
||||
if exc is not None:
|
||||
payload.setdefault("error_type", exc.__class__.__name__)
|
||||
payload.setdefault("error_message", str(exc))
|
||||
payload.setdefault("traceback", "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
|
||||
return payload
|
||||
|
||||
|
||||
def log_upload_resource_event(
|
||||
*,
|
||||
event_type: str,
|
||||
module: str | None = None,
|
||||
user_id: str | None = None,
|
||||
resource_id: str | None = None,
|
||||
source_model: str | None = None,
|
||||
source_id: str | None = None,
|
||||
message: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
error: str | None = None,
|
||||
exc: BaseException | None = None,
|
||||
event_status: str = "success",
|
||||
) -> None:
|
||||
"""Write an upload-resource operation log.
|
||||
|
||||
日志失败不能影响主业务流程;失败时降级到标准 logger。
|
||||
detail 只接收普通 dict,禁止传 ORM 对象,避免 commit/rollback 后懒加载异常。
|
||||
"""
|
||||
payload = dict(detail or {})
|
||||
if resource_id:
|
||||
payload["resource_id"] = resource_id
|
||||
if source_model:
|
||||
payload["source_model"] = source_model
|
||||
if source_id:
|
||||
payload["source_id"] = source_id
|
||||
if exc is not None:
|
||||
payload = build_exception_detail(exc, payload)
|
||||
error = error or str(exc)
|
||||
event_status = "failed"
|
||||
try:
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=event_type,
|
||||
module=module or DOMAIN,
|
||||
event_status=event_status,
|
||||
source="service",
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
detail=payload,
|
||||
error=error,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - 日志降级,不能影响主流程
|
||||
logger.exception(
|
||||
"upload_resource operation log failed: event_type=%s user_id=%s resource_id=%s detail=%s error=%s",
|
||||
event_type,
|
||||
user_id,
|
||||
resource_id,
|
||||
payload,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
def log_upload_resource_exception(
|
||||
*,
|
||||
event_type: str,
|
||||
message: str | None = None,
|
||||
user_id: str | None = None,
|
||||
resource_id: str | None = None,
|
||||
resource_ids: list[str] | None = None,
|
||||
module: str | None = None,
|
||||
source_model: str | None = None,
|
||||
source_id: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
exc: BaseException | None = None,
|
||||
) -> None:
|
||||
"""统一记录 UploadResource 相关异常。"""
|
||||
payload = _exception_payload(exc, detail)
|
||||
if resource_ids is not None:
|
||||
payload["resource_ids"] = list(resource_ids)
|
||||
log_upload_resource_event(
|
||||
event_type=event_type,
|
||||
module=module,
|
||||
user_id=user_id,
|
||||
resource_id=resource_id,
|
||||
source_model=source_model,
|
||||
source_id=source_id,
|
||||
message=message,
|
||||
detail=payload,
|
||||
error=str(exc) if exc else None,
|
||||
event_status="failed",
|
||||
)
|
||||
|
||||
|
||||
async def safe_rollback_with_log(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
event_type: str,
|
||||
message: str | None = None,
|
||||
user_id: str | None = None,
|
||||
module: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
original_exc: BaseException | None = None,
|
||||
) -> None:
|
||||
"""Rollback with rollback-failure logging.
|
||||
|
||||
只记录 rollback 自身异常;不吞掉主异常,调用方继续 raise 原始异常。
|
||||
"""
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_exc: # noqa: BLE001
|
||||
payload = dict(detail or {})
|
||||
if original_exc is not None:
|
||||
payload["original_error_type"] = original_exc.__class__.__name__
|
||||
payload["original_error_message"] = str(original_exc)
|
||||
log_upload_resource_exception(
|
||||
event_type=event_type,
|
||||
message=message or "数据库回滚失败",
|
||||
user_id=user_id,
|
||||
module=module,
|
||||
detail=payload,
|
||||
exc=rollback_exc,
|
||||
)
|
||||
@@ -0,0 +1,218 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceTypeEnum
|
||||
|
||||
COMMON_IMAGE_RE = re.compile(r"^images/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/video_img_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
COMMON_VIDEO_RE = re.compile(r"^videos/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/video_ref_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
COMMON_AUDIO_RE = re.compile(r"^audios/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/audio_ref_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
MODULE_RE = re.compile(r"^(?P<module>hot_opening_replicate|shot_replicate)/(?P<kind>images|videos)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>video_img|video_ref)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
SHOT_SEGMENT_RE = re.compile(r"^shot_segments/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<segment_id>[^/]+)\.mp4$")
|
||||
LEGACY_GEN_RE = re.compile(r"^(?P<kind>images|videos)/gen_(?P<user_id>[^_]+)_(?P<rand>[0-9a-zA-Z]+)\.(?P<ext>[^/]+)$")
|
||||
|
||||
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".svg"}
|
||||
VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"}
|
||||
AUDIO_EXTS = {".mp3", ".wav", ".m4a", ".aac", ".flac"}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ParsedUploadPath:
|
||||
storage_path: str
|
||||
resource_url: str
|
||||
file_name: str
|
||||
file_ext: str
|
||||
file_size_bytes: int
|
||||
module: str
|
||||
resource_type: str
|
||||
user_id: str | None
|
||||
created_at: datetime | None
|
||||
source_model: str | None = None
|
||||
source_id: str | None = None
|
||||
skip_reason: str | None = None
|
||||
|
||||
|
||||
def upload_root() -> Path:
|
||||
return Path(settings.UPLOAD_LOCAL_PATH).resolve()
|
||||
|
||||
|
||||
def to_abs_path(path: str | os.PathLike[str]) -> Path:
|
||||
p = Path(path)
|
||||
return p if p.is_absolute() else Path.cwd() / p
|
||||
|
||||
|
||||
def normalize_storage_path(path: str | os.PathLike[str]) -> str:
|
||||
return str(to_abs_path(path).resolve())
|
||||
|
||||
|
||||
def upload_url_to_storage_path(url: str | None) -> str | None:
|
||||
if not url:
|
||||
return None
|
||||
value = str(url).strip()
|
||||
if not value:
|
||||
return None
|
||||
value = urlsplit(value).path or value
|
||||
if not value.startswith("/uploads/"):
|
||||
return None
|
||||
rel = value[len("/uploads/"):].lstrip("/")
|
||||
if not rel or ".." in Path(rel).parts:
|
||||
return None
|
||||
return str((upload_root() / rel).resolve())
|
||||
|
||||
|
||||
def storage_path_to_upload_url(path: str | os.PathLike[str]) -> str:
|
||||
abs_path = to_abs_path(path).resolve()
|
||||
rel = abs_path.relative_to(upload_root()).as_posix()
|
||||
return f"/uploads/{rel}"
|
||||
|
||||
|
||||
def _parse_created_at(parts: dict[str, str], fallback: datetime | None = None) -> datetime | None:
|
||||
ymd = parts.get("ymd")
|
||||
hms = parts.get("hms")
|
||||
try:
|
||||
if ymd and hms:
|
||||
return datetime.strptime(f"{ymd}_{hms}", "%Y%m%d_%H%M%S")
|
||||
return datetime(int(parts["year"]), int(parts["month"]), int(parts["day"]))
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def _file_size(path: Path) -> int:
|
||||
try:
|
||||
return int(path.stat().st_size)
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def _base(path: Path, module: str, resource_type: str, user_id: str | None, created_at: datetime | None) -> ParsedUploadPath:
|
||||
return ParsedUploadPath(
|
||||
storage_path=normalize_storage_path(path),
|
||||
resource_url=storage_path_to_upload_url(path),
|
||||
file_name=path.name,
|
||||
file_ext=path.suffix.lower().lstrip("."),
|
||||
file_size_bytes=_file_size(path),
|
||||
module=module,
|
||||
resource_type=resource_type,
|
||||
user_id=user_id,
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
|
||||
def parse_upload_path(path: str | os.PathLike[str], *, include_legacy: bool = False) -> ParsedUploadPath | None:
|
||||
abs_path = to_abs_path(path).resolve()
|
||||
if not abs_path.is_file():
|
||||
return None
|
||||
try:
|
||||
rel = abs_path.relative_to(upload_root()).as_posix()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
ignored_prefixes = ("home_materials/",)
|
||||
if rel in {"site_logo.png"} or rel.startswith(ignored_prefixes) or rel.startswith("pdf_"):
|
||||
return ParsedUploadPath(
|
||||
storage_path=normalize_storage_path(abs_path),
|
||||
resource_url="",
|
||||
file_name=abs_path.name,
|
||||
file_ext=abs_path.suffix.lower().lstrip("."),
|
||||
file_size_bytes=_file_size(abs_path),
|
||||
module=UploadResourceModuleEnum.COMMON.value,
|
||||
resource_type="unknown",
|
||||
user_id=None,
|
||||
created_at=None,
|
||||
skip_reason="ignored_path",
|
||||
)
|
||||
|
||||
for pattern, rtype in (
|
||||
(COMMON_IMAGE_RE, UploadResourceTypeEnum.IMAGE.value),
|
||||
(COMMON_VIDEO_RE, UploadResourceTypeEnum.VIDEO.value),
|
||||
(COMMON_AUDIO_RE, UploadResourceTypeEnum.AUDIO.value),
|
||||
):
|
||||
m = pattern.match(rel)
|
||||
if m:
|
||||
d = m.groupdict()
|
||||
return _base(abs_path, UploadResourceModuleEnum.COMMON.value, rtype, d.get("user_id"), _parse_created_at(d))
|
||||
|
||||
m = MODULE_RE.match(rel)
|
||||
if m:
|
||||
d = m.groupdict()
|
||||
kind = d.get("kind")
|
||||
rtype = UploadResourceTypeEnum.IMAGE.value if kind == "images" else UploadResourceTypeEnum.VIDEO.value
|
||||
return _base(abs_path, d["module"], rtype, d.get("user_id"), _parse_created_at(d))
|
||||
|
||||
m = SHOT_SEGMENT_RE.match(rel)
|
||||
if m:
|
||||
d = m.groupdict()
|
||||
parsed = _base(abs_path, UploadResourceModuleEnum.SHOT_REPLICATE.value, UploadResourceTypeEnum.SHOT_SEGMENT.value, None, _parse_created_at(d))
|
||||
parsed.source_model = "ShotReplicateSegment"
|
||||
parsed.source_id = d["segment_id"]
|
||||
return parsed
|
||||
|
||||
if include_legacy:
|
||||
m = LEGACY_GEN_RE.match(rel)
|
||||
if m:
|
||||
d = m.groupdict()
|
||||
ext = abs_path.suffix.lower()
|
||||
if ext in IMAGE_EXTS:
|
||||
rtype = UploadResourceTypeEnum.IMAGE.value
|
||||
elif ext in VIDEO_EXTS:
|
||||
rtype = UploadResourceTypeEnum.VIDEO.value
|
||||
else:
|
||||
return None
|
||||
try:
|
||||
created = datetime.fromtimestamp(abs_path.stat().st_mtime)
|
||||
except OSError:
|
||||
created = None
|
||||
return _base(abs_path, UploadResourceModuleEnum.COMMON.value, rtype, d.get("user_id"), created)
|
||||
|
||||
return ParsedUploadPath(
|
||||
storage_path=normalize_storage_path(abs_path),
|
||||
resource_url="",
|
||||
file_name=abs_path.name,
|
||||
file_ext=abs_path.suffix.lower().lstrip("."),
|
||||
file_size_bytes=_file_size(abs_path),
|
||||
module=UploadResourceModuleEnum.COMMON.value,
|
||||
resource_type="unknown",
|
||||
user_id=None,
|
||||
created_at=None,
|
||||
skip_reason="unmatched_path",
|
||||
)
|
||||
|
||||
|
||||
def iter_files(root: str | os.PathLike[str]) -> Iterable[Path]:
|
||||
base = to_abs_path(root).resolve()
|
||||
if not base.exists():
|
||||
return []
|
||||
return (p for p in base.rglob("*") if p.is_file())
|
||||
|
||||
|
||||
def build_upload_destination(*, module: str, resource_type: str, user_id: str, original_filename: str | None, gen_type: str = "video") -> tuple[Path, str, str]:
|
||||
now = datetime.now()
|
||||
date_dir = now.strftime("%Y/%m/%d")
|
||||
ext = Path(original_filename or "").suffix.lower()
|
||||
if not ext:
|
||||
ext = ".mp4" if resource_type == UploadResourceTypeEnum.VIDEO.value else ".mp3" if resource_type == UploadResourceTypeEnum.AUDIO.value else ".png"
|
||||
|
||||
timestamp = now.strftime("%Y%m%d_%H%M%S")
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
if resource_type == UploadResourceTypeEnum.AUDIO.value:
|
||||
filename = f"audio_ref_{user_id}_{timestamp}_{suffix}{ext}"
|
||||
rel_dir = Path("audios") / date_dir
|
||||
elif resource_type == UploadResourceTypeEnum.VIDEO.value:
|
||||
filename = f"video_ref_{user_id}_{timestamp}_{suffix}{ext}"
|
||||
rel_dir = Path("videos") / date_dir if module == UploadResourceModuleEnum.COMMON.value else Path(module) / "videos" / date_dir
|
||||
else:
|
||||
filename = f"{gen_type}_img_{user_id}_{timestamp}_{suffix}{ext}"
|
||||
rel_dir = Path("images") / date_dir if module == UploadResourceModuleEnum.COMMON.value else Path(module) / "images" / date_dir
|
||||
|
||||
dir_path = upload_root() / rel_dir
|
||||
file_path = dir_path / filename
|
||||
url = f"/uploads/{(rel_dir / filename).as_posix()}"
|
||||
return file_path, url, filename
|
||||
@@ -11,6 +11,7 @@ from app.enums.credit_record import CreditRecordBillingScene, CreditRecordOwnerT
|
||||
from app.enums.shot_replicate import (
|
||||
ModuleCodeEnum,
|
||||
ShotAnalysisStatusEnum,
|
||||
ShotReplicateLogEventEnum,
|
||||
ShotSegmentAnalysisStatusEnum,
|
||||
ShotSegmentSourceModeEnum,
|
||||
ShotSplitStatusEnum,
|
||||
@@ -40,6 +41,7 @@ from app.services.shot_video_analysis_service import analyze_video_for_shot_spli
|
||||
from app.services.generation_billing_service import charge_shot_video_analysis_usage
|
||||
from app.services.shot_video_split_service import split_video_segment_async
|
||||
from app.services.upload_video_asset_service import validate_split_range
|
||||
from app.services.upload_resource import record_shot_segment_upload_resource
|
||||
from app.tasks.async_runner import run_async
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
@@ -113,7 +115,7 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
||||
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type="SHOT_ANALYSIS_STARTED",
|
||||
event_type=ShotReplicateLogEventEnum.ANALYSIS_STARTED.value,
|
||||
project_id=task_set_id,
|
||||
user_id=task_set_user_id,
|
||||
message="原视频拆镜分析开始",
|
||||
@@ -121,7 +123,7 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
||||
)
|
||||
|
||||
async with async_session() as db:
|
||||
analyzed = await analyze_video_for_shot_split(db, video_url or "", user_id=task_set_user_id, mode="full_breakdown")
|
||||
analyzed = await analyze_video_for_shot_split(db, video_url or "", user_id=task_set_user_id, mode="full_breakdown", task_set_id=task_set_id, trace_id=f"shot-task-set-analysis:{task_set_id}")
|
||||
result = await db.execute(
|
||||
select(ShotReplicateTaskSet)
|
||||
.where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None))
|
||||
@@ -157,7 +159,7 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
||||
await cleanup_active_if_terminal(db, object_type=OBJECT_SHOT_TASK_SET_ANALYSIS, object_id=task_set_id)
|
||||
|
||||
log_module_prompt_event(
|
||||
event_type="SHOT_ANALYSIS_SUCCESS",
|
||||
event_type=ShotReplicateLogEventEnum.ANALYSIS_SUCCESS.value,
|
||||
project_id=task_set_id,
|
||||
step_id=task_set_id,
|
||||
user_id=task_set_user_id or "",
|
||||
@@ -169,7 +171,7 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
||||
)
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type="SHOT_ANALYSIS_SUCCESS",
|
||||
event_type=ShotReplicateLogEventEnum.ANALYSIS_SUCCESS.value,
|
||||
project_id=task_set_id,
|
||||
user_id=task_set_user_id,
|
||||
message="原视频拆镜分析成功",
|
||||
@@ -195,7 +197,7 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
||||
await remove_active_task(object_type=OBJECT_SHOT_TASK_SET_ANALYSIS, object_id=task_set_id)
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type="SHOT_ANALYSIS_FAILED",
|
||||
event_type=ShotReplicateLogEventEnum.ANALYSIS_FAILED.value,
|
||||
project_id=task_set_id,
|
||||
user_id=task_set_user_id,
|
||||
message="原视频拆镜分析失败",
|
||||
@@ -241,7 +243,7 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
|
||||
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type="SHOT_SEGMENT_ANALYSIS_STARTED",
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_STARTED.value,
|
||||
project_id=task_set_id,
|
||||
step_id=segment_id,
|
||||
user_id=user_id,
|
||||
@@ -250,7 +252,7 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
|
||||
)
|
||||
|
||||
async with async_session() as db:
|
||||
analyzed = await analyze_video_for_shot_split(db, video_url or "", user_id=user_id, mode="summary_only")
|
||||
analyzed = await analyze_video_for_shot_split(db, video_url or "", user_id=user_id, mode="summary_only", task_set_id=task_set_id, segment_id=segment_id, trace_id=f"shot-segment-analysis:{segment_id}")
|
||||
result = await db.execute(
|
||||
select(ShotReplicateSegment)
|
||||
.where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None))
|
||||
@@ -287,7 +289,7 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
|
||||
await cleanup_active_if_terminal(db, object_type=OBJECT_SHOT_SEGMENT_ANALYSIS, object_id=segment_id)
|
||||
|
||||
log_module_prompt_event(
|
||||
event_type="SHOT_SEGMENT_ANALYSIS_SUCCESS",
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_SUCCESS.value,
|
||||
project_id=task_set_id,
|
||||
step_id=segment_id,
|
||||
user_id=user_id or "",
|
||||
@@ -299,7 +301,7 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
|
||||
)
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type="SHOT_SEGMENT_ANALYSIS_SUCCESS",
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_SUCCESS.value,
|
||||
project_id=task_set_id,
|
||||
step_id=segment_id,
|
||||
user_id=user_id,
|
||||
@@ -326,7 +328,7 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
|
||||
await remove_active_task(object_type=OBJECT_SHOT_SEGMENT_ANALYSIS, object_id=segment_id)
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type="SHOT_SEGMENT_ANALYSIS_FAILED",
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_FAILED.value,
|
||||
project_id=task_set_id,
|
||||
step_id=segment_id,
|
||||
user_id=user_id,
|
||||
@@ -466,6 +468,13 @@ async def _run_split_one_segment(segment_id: str) -> None:
|
||||
return
|
||||
segment.segment_video_url = split_result.url
|
||||
segment.segment_video_path = split_result.path
|
||||
await record_shot_segment_upload_resource(
|
||||
db,
|
||||
segment=segment,
|
||||
storage_path=split_result.path,
|
||||
resource_url=split_result.url,
|
||||
file_size_bytes=split_result.file_size_bytes,
|
||||
)
|
||||
segment.split_status = ShotSplitStatusEnum.COMPLETED.value
|
||||
segment.split_completed_at = _now()
|
||||
segment.split_lease_until = None
|
||||
|
||||
+116
-116
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-C6k2TIpY.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CYqrWKKz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+172
-14
@@ -116,20 +116,30 @@ export async function optimizePrompt(
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadAudio(file: File): Promise<{ url: string; filename: string }> {
|
||||
export interface UploadResourceResult {
|
||||
url: string;
|
||||
filename: string;
|
||||
type?: string;
|
||||
module?: string;
|
||||
resource_id?: string;
|
||||
file_size_bytes?: number;
|
||||
duration_seconds?: number | null;
|
||||
}
|
||||
|
||||
export async function uploadAudio(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-audio`, {
|
||||
const query = typeof durationSeconds === 'number' && durationSeconds > 0 ? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}` : '';
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-audio${query}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('图片上传失败');
|
||||
const data = await res.json();
|
||||
return { url: data.url, filename: data.filename };
|
||||
if (!res.ok) throw new Error('音频上传失败');
|
||||
return await res.json();
|
||||
}
|
||||
export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
|
||||
export async function uploadImage(file: File): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
@@ -139,21 +149,74 @@ export async function uploadImage(file: File): Promise<{ url: string; filename:
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('图片上传失败');
|
||||
const data = await res.json();
|
||||
return { url: data.url, filename: data.filename };
|
||||
return await res.json();
|
||||
}
|
||||
export async function uploadVideo(file: File): Promise<{ url: string; filename: string }> {
|
||||
export async function uploadVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-video`, {
|
||||
const query = typeof durationSeconds === 'number' && durationSeconds > 0 ? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}` : '';
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-video${query}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('视频上传失败');
|
||||
const data = await res.json();
|
||||
return { url: data.url, filename: data.filename };
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function uploadHotOpeningImage(file: File): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/hot-opening-replications/upload-image`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('图片上传失败');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function uploadHotOpeningVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const query = typeof durationSeconds === 'number' && durationSeconds > 0 ? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}` : '';
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/hot-opening-replications/upload-video${query}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('视频上传失败');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function uploadShotReplicateImage(file: File): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/shot-replications/upload-image`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('图片上传失败');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function uploadShotReplicateVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const query = typeof durationSeconds === 'number' && durationSeconds > 0 ? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}` : '';
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/shot-replications/upload-video${query}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('视频上传失败');
|
||||
return await res.json();
|
||||
}
|
||||
export async function deleteUpload(url: string): Promise<void> {
|
||||
await api.post(`/generation-records/delete-file?url=${encodeURIComponent(url)}`);
|
||||
@@ -761,6 +824,100 @@ export async function deleteResourcesMaterial(params:any): Promise<any> {
|
||||
return api.delete(`/generation-ai/history/batch`, params);
|
||||
}
|
||||
|
||||
// ── UploadResource History ───────────────────────────────
|
||||
export interface UploadResourceMediaReferenceOut {
|
||||
name?: string | null;
|
||||
type: 'image' | 'video' | 'audio';
|
||||
url: string;
|
||||
label?: string | null;
|
||||
duration?: number | null;
|
||||
source: 'upload_resource';
|
||||
uploadResourceId: string;
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryItemOut {
|
||||
id: string;
|
||||
sourceType: 'upload_resource';
|
||||
historySource: 'upload_resource';
|
||||
historySourceLabel: string;
|
||||
module: string;
|
||||
moduleLabel: string;
|
||||
resourceType: 'image' | 'video' | 'audio';
|
||||
resourceTypeLabel: string;
|
||||
resourceUrl: string;
|
||||
displayUrl: string;
|
||||
previewUrl: string;
|
||||
imageUrl?: string | null;
|
||||
videoUrl?: string | null;
|
||||
audioUrl?: string | null;
|
||||
fileName?: string | null;
|
||||
fileExt?: string | null;
|
||||
mimeType?: string | null;
|
||||
fileSizeBytes: number;
|
||||
durationSeconds?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
bindStatus: string;
|
||||
deletePolicy: string;
|
||||
deletable: boolean;
|
||||
mediaReference: UploadResourceMediaReferenceOut;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryDayGroupOut {
|
||||
generatedDate: string;
|
||||
total: number;
|
||||
page: number;
|
||||
items: UploadResourceHistoryItemOut[];
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryGroupedOut {
|
||||
totalDays: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
groups: UploadResourceHistoryDayGroupOut[];
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryDayItemsOut {
|
||||
generatedDate: string;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
items: UploadResourceHistoryItemOut[];
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryQueryParams {
|
||||
resourceType?: 'image' | 'video' | 'audio' | '';
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
scene?: 'record' | 'picker' | string;
|
||||
}
|
||||
|
||||
function buildUploadResourceHistoryQuery(params: UploadResourceHistoryQueryParams = {}): string {
|
||||
const query = new URLSearchParams();
|
||||
if (params.resourceType) query.set('resource_type', params.resourceType);
|
||||
if (params.page) query.set('page', String(params.page));
|
||||
if (params.pageSize) query.set('page_size', String(params.pageSize));
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
if (params.scene) query.set('scene', params.scene);
|
||||
const qs = query.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
export async function getUploadResourceHistory(params: UploadResourceHistoryQueryParams = {}): Promise<UploadResourceHistoryGroupedOut> {
|
||||
return api.get<UploadResourceHistoryGroupedOut>(`/upload-resources/history${buildUploadResourceHistoryQuery(params)}`);
|
||||
}
|
||||
|
||||
export async function getUploadResourceHistoryItems(generatedDate: string, params: UploadResourceHistoryQueryParams = {}): Promise<UploadResourceHistoryDayItemsOut> {
|
||||
return api.get<UploadResourceHistoryDayItemsOut>(`/upload-resources/history/${generatedDate}${buildUploadResourceHistoryQuery(params)}`);
|
||||
}
|
||||
|
||||
export async function deleteUploadResourceHistoryBatch(resourceIds: string[]): Promise<any> {
|
||||
return api.delete('/upload-resources/history/batch', { resource_ids: resourceIds });
|
||||
}
|
||||
|
||||
|
||||
export async function getPrivatePortraitConfig(): Promise<PrivatePortraitConfig> {
|
||||
return api.get<PrivatePortraitConfig>('/private-portrait/config');
|
||||
@@ -942,8 +1099,9 @@ export async function revokeInvitation(invitationId: string): Promise<void> {
|
||||
await api.delete(`/team/invitations/${invitationId}`);
|
||||
}
|
||||
|
||||
export async function getPendingJoinRequests(): Promise<any[]> {
|
||||
return api.get('/team/join-requests');
|
||||
export async function getPendingJoinRequests(status?: string): Promise<any[]> {
|
||||
const url = status ? `/team/join-requests?status=${status}` : '/team/join-requests';
|
||||
return api.get(url);
|
||||
}
|
||||
|
||||
export async function handleJoinRequest(requestId: string, action: 'approve' | 'reject', note?: string): Promise<void> {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Popover, Tooltip, Modal } from 'antd';
|
||||
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { FolderOpenOutlined, DatabaseOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset } from '../types';
|
||||
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset, UploadResourceHistoryItem } from '../types';
|
||||
|
||||
import PrivatePortraitAssetPicker from './privatePortrait/picker/AssetPicker';
|
||||
import UploadResourceHistoryPicker from './uploadResource/UploadResourceHistoryPicker';
|
||||
|
||||
interface UploadSelectorProps {
|
||||
children: React.ReactNode;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
onLocalSelect?: (files: File[]) => void;
|
||||
onHistorySelect?: (items: any[]) => void;
|
||||
|
||||
onHistorySelect?: (items: UploadResourceHistoryItem[]) => void;
|
||||
/** 兼容旧页面:由 UploadSelector 内部打开素材选择器,确认后回传素材数组。 */
|
||||
|
||||
onPortraitSelect?: (items: PrivatePortraitSelectableAsset[]) => void;
|
||||
onPortraitLibrarySelect?: (libraryType: PrivatePortraitLibraryType) => void;
|
||||
uploading?: boolean;
|
||||
@@ -81,11 +87,6 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
||||
setHistoryModalVisible(true);
|
||||
};
|
||||
|
||||
const confirmHistorySelection = () => {
|
||||
onHistorySelect?.([]);
|
||||
setHistoryModalVisible(false);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div style={{ padding: '4px 0', minWidth: 180 }}>
|
||||
<div
|
||||
@@ -211,39 +212,15 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="选择资产素材"
|
||||
<UploadResourceHistoryPicker
|
||||
open={historyModalVisible}
|
||||
onCancel={() => setHistoryModalVisible(false)}
|
||||
footer={null}
|
||||
width="80%"
|
||||
centered
|
||||
destroyOnHidden
|
||||
>
|
||||
<div style={{ minHeight: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center', color: '#94a3b8', fontSize: 14 }}>
|
||||
暂无资产图片
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16, paddingTop: 16, borderTop: '1px solid #e2e8f0' }}>
|
||||
<button
|
||||
onClick={confirmHistorySelection}
|
||||
style={{
|
||||
padding: '8px 24px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: '#8b5cf6',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
应用
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
onClose={() => setHistoryModalVisible(false)}
|
||||
onSelect={(items) => {
|
||||
onHistorySelect?.(items);
|
||||
setHistoryModalVisible(false);
|
||||
}}
|
||||
allowedTypes={accept.includes('audio') ? ['image', 'video', 'audio'] : accept.includes('video') ? ['image', 'video'] : ['image']}
|
||||
/>
|
||||
|
||||
<PrivatePortraitAssetPicker
|
||||
open={portraitPickerOpen}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AudioOutlined, DeleteOutlined, DownloadOutlined, EyeOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||
import { Button, Checkbox, Empty, Input, message, Modal, Pagination, Select, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { deleteUpload, deleteUploadResourceHistoryBatch, getUploadResourceHistory, getUploadResourceHistoryItems } from '../../api';
|
||||
import type { UploadResourceHistoryDayGroup, UploadResourceHistoryItem } from '../../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type MediaTypeFilter = '' | 'image' | 'video' | 'audio';
|
||||
|
||||
const buildPreviewUrl = (url: string) => {
|
||||
if (!url) return '';
|
||||
if (/^(https?:|data:|blob:)/i.test(url)) return url;
|
||||
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
|
||||
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
|
||||
const typeIcon = (type: string) => {
|
||||
if (type === 'video') return <VideoCameraOutlined />;
|
||||
if (type === 'audio') return <AudioOutlined />;
|
||||
return <PictureOutlined />;
|
||||
};
|
||||
|
||||
const typeLabel = (type: string) => {
|
||||
if (type === 'video') return '视频';
|
||||
if (type === 'audio') return '音频';
|
||||
return '图片';
|
||||
};
|
||||
|
||||
const bytesText = (bytes?: number | null) => {
|
||||
const value = Number(bytes || 0);
|
||||
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${value} B`;
|
||||
};
|
||||
|
||||
const UploadResourceHistoryPanel: React.FC = () => {
|
||||
const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [groups, setGroups] = useState<UploadResourceHistoryDayGroup[]>([]);
|
||||
const [totalDays, setTotalDays] = useState(0);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [previewItem, setPreviewItem] = useState<UploadResourceHistoryItem | null>(null);
|
||||
|
||||
const allItems = useMemo(() => groups.flatMap((group) => group.items || []), [groups]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getUploadResourceHistory({
|
||||
resourceType: resourceType || undefined,
|
||||
keyword: keyword || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
scene: 'record',
|
||||
});
|
||||
setGroups(res.groups || []);
|
||||
setTotalDays(res.totalDays || 0);
|
||||
setSelectedIds(new Set());
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载历史上传素材失败');
|
||||
setGroups([]);
|
||||
setTotalDays(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [keyword, page, pageSize, resourceType]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
const ids = allItems.map((item) => item.id);
|
||||
setSelectedIds((prev) => (prev.size === ids.length ? new Set() : new Set(ids)));
|
||||
};
|
||||
|
||||
const handleLoadMoreDay = async (group: UploadResourceHistoryDayGroup) => {
|
||||
const nextPage = (group.page || 1) + 1;
|
||||
try {
|
||||
const res = await getUploadResourceHistoryItems(group.generatedDate, {
|
||||
resourceType: resourceType || undefined,
|
||||
keyword: keyword || undefined,
|
||||
page: nextPage,
|
||||
pageSize: 10,
|
||||
scene: 'record',
|
||||
});
|
||||
setGroups((prev) => prev.map((item) => item.generatedDate === group.generatedDate ? { ...item, page: nextPage, items: [...item.items, ...(res.items || [])] } : item));
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载更多失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOne = (item: UploadResourceHistoryItem) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除历史上传素材',
|
||||
content: `确定删除 ${item.fileName || item.id} 吗?删除后会释放上传容量,并在提交成功后清理真实文件。`,
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteUpload(item.resourceUrl);
|
||||
message.success('删除成功');
|
||||
load();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedIds.size === 0) {
|
||||
message.warning('请先选择要删除的上传素材');
|
||||
return;
|
||||
}
|
||||
if (selectedIds.size > 30) {
|
||||
message.warning('一次最多删除 30 条上传素材');
|
||||
return;
|
||||
}
|
||||
const ids = Array.from(selectedIds);
|
||||
Modal.confirm({
|
||||
title: '确认批量删除历史上传素材',
|
||||
content: `确定删除选中的 ${ids.length} 条上传素材吗?删除后会释放上传容量,并在提交成功后清理真实文件。`,
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await deleteUploadResourceHistoryBatch(ids);
|
||||
message.success(`删除成功,释放 ${bytesText(res?.releasedSizeBytes || 0)}`);
|
||||
load();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '批量删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownload = (item: UploadResourceHistoryItem) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = buildPreviewUrl(item.resourceUrl);
|
||||
link.download = item.fileName || item.id;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const renderMedia = (item: UploadResourceHistoryItem, size: 'card' | 'preview' = 'card') => {
|
||||
const url = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
|
||||
const style = size === 'card'
|
||||
? { width: '100%', height: 130, objectFit: 'cover' as const }
|
||||
: { width: '100%', maxHeight: 620, objectFit: 'contain' as const };
|
||||
if (item.resourceType === 'image') return <img src={url} alt={item.fileName || item.id} style={style} />;
|
||||
if (item.resourceType === 'video') return <video src={url} controls={size === 'preview'} muted={size === 'card'} style={style} />;
|
||||
return <audio src={url} controls style={{ width: '100%' }} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
value={resourceType}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '图片', value: 'image' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '音频', value: 'audio' },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setResourceType(value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
value={keyword}
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索文件名或URL"
|
||||
style={{ width: 240 }}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={() => {
|
||||
setPage(1);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button onClick={selectAll}>{selectedIds.size && selectedIds.size === allItems.length ? '取消全选' : '全选'}</Button>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={handleBatchDelete}>批量删除 ({selectedIds.size})</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{groups.length === 0 ? (
|
||||
<Empty description="暂无可展示的历史上传素材" style={{ padding: '60px 0' }} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>
|
||||
{groups.map((group) => (
|
||||
<div key={group.generatedDate}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#1e293b' }}>{group.generatedDate}</div>
|
||||
<Text type="secondary">共 {group.total} 个素材</Text>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(190px, 1fr))', gap: 16 }}>
|
||||
{group.items.map((item) => {
|
||||
const checked = selectedIds.has(item.id);
|
||||
return (
|
||||
<div key={item.id} style={{ border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0', borderRadius: 16, overflow: 'hidden', background: '#fff', boxShadow: '0 10px 24px rgba(15,23,42,0.06)' }}>
|
||||
<div style={{ position: 'relative', background: '#f1f5f9' }}>
|
||||
{renderMedia(item)}
|
||||
<Checkbox checked={checked} onChange={() => toggle(item.id)} style={{ position: 'absolute', top: 10, left: 10, background: '#fff', borderRadius: 6, padding: 4 }} />
|
||||
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ position: 'absolute', top: 10, right: 10, margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
|
||||
</div>
|
||||
<div style={{ padding: 12 }}>
|
||||
<Text ellipsis title={item.fileName || item.id} style={{ display: 'block', fontWeight: 600, color: '#334155' }}>{item.fileName || item.id}</Text>
|
||||
<Space size={4} wrap style={{ marginTop: 8 }}>
|
||||
<Tag style={{ margin: 0 }}>{item.moduleLabel}</Tag>
|
||||
<Tag style={{ margin: 0 }}>{bytesText(item.fileSizeBytes)}</Tag>
|
||||
{item.durationSeconds ? <Tag style={{ margin: 0 }}>{Number(item.durationSeconds).toFixed(1)}s</Tag> : null}
|
||||
</Space>
|
||||
<Space style={{ marginTop: 12 }}>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => setPreviewItem(item)}>预览</Button>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={() => handleDownload(item)}>下载</Button>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDeleteOne(item)}>删除</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{group.items.length < group.total && (
|
||||
<div style={{ textAlign: 'center', marginTop: 14 }}>
|
||||
<Button onClick={() => handleLoadMoreDay(group)}>加载更多</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 24 }}>
|
||||
<Pagination current={page} pageSize={pageSize} total={totalDays} showSizeChanger pageSizeOptions={[5, 10]} onChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }} />
|
||||
</div>
|
||||
|
||||
<Modal open={!!previewItem} title={previewItem?.fileName || '预览'} footer={null} width={900} centered destroyOnHidden onCancel={() => setPreviewItem(null)}>
|
||||
{previewItem ? renderMedia(previewItem, 'preview') : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadResourceHistoryPanel;
|
||||
@@ -0,0 +1,251 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AudioOutlined, CheckOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||
import { Button, Empty, Input, message, Modal, Pagination, Select, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { getUploadResourceHistoryItems } from '../../api';
|
||||
import type { UploadResourceHistoryItem } from '../../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type MediaTypeFilter = '' | 'image' | 'video' | 'audio';
|
||||
|
||||
interface UploadResourceHistoryPickerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (items: UploadResourceHistoryItem[]) => void;
|
||||
allowedTypes?: Array<'image' | 'video' | 'audio'>;
|
||||
selectedIds?: string[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
const buildPreviewUrl = (url: string) => {
|
||||
if (!url) return '';
|
||||
if (/^(https?:|data:|blob:)/i.test(url)) return url;
|
||||
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
|
||||
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
|
||||
const typeIcon = (type: string) => {
|
||||
if (type === 'video') return <VideoCameraOutlined />;
|
||||
if (type === 'audio') return <AudioOutlined />;
|
||||
return <PictureOutlined />;
|
||||
};
|
||||
|
||||
const typeLabel = (type: string) => {
|
||||
if (type === 'video') return '视频';
|
||||
if (type === 'audio') return '音频';
|
||||
return '图片';
|
||||
};
|
||||
|
||||
const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
allowedTypes,
|
||||
selectedIds = [],
|
||||
title = '选择历史上传素材',
|
||||
}) => {
|
||||
const [date, setDate] = useState(today());
|
||||
const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [items, setItems] = useState<UploadResourceHistoryItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [checkedMap, setCheckedMap] = useState<Map<string, UploadResourceHistoryItem>>(new Map());
|
||||
|
||||
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
const effectiveAllowedTypes = useMemo(() => new Set(allowedTypes || ['image', 'video', 'audio']), [allowedTypes]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getUploadResourceHistoryItems(date, {
|
||||
resourceType: resourceType || undefined,
|
||||
keyword: keyword || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
scene: 'picker',
|
||||
});
|
||||
const filtered = (res.items || []).filter((item) => effectiveAllowedTypes.has(item.resourceType));
|
||||
setItems(filtered);
|
||||
setTotal(res.total || 0);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载历史上传素材失败');
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [date, effectiveAllowedTypes, keyword, open, page, pageSize, resourceType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) load();
|
||||
}, [open, load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setCheckedMap(new Map());
|
||||
}, [open]);
|
||||
|
||||
const toggle = (item: UploadResourceHistoryItem) => {
|
||||
if (selectedIdSet.has(item.id)) {
|
||||
message.warning('该素材已经在参考内容中');
|
||||
return;
|
||||
}
|
||||
setCheckedMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(item.id)) next.delete(item.id);
|
||||
else next.set(item.id, item);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
const selected = Array.from(checkedMap.values());
|
||||
if (selected.length === 0) {
|
||||
message.warning('请先选择历史素材');
|
||||
return;
|
||||
}
|
||||
onSelect(selected);
|
||||
setCheckedMap(new Map());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const typeOptions = useMemo(() => {
|
||||
const all = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '图片', value: 'image' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '音频', value: 'audio' },
|
||||
];
|
||||
return all.filter((opt) => !opt.value || effectiveAllowedTypes.has(opt.value as any));
|
||||
}, [effectiveAllowedTypes]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={920}
|
||||
centered
|
||||
destroyOnHidden
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onClose}>取消</Button>,
|
||||
<Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }}>
|
||||
应用选择 ({checkedMap.size})
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Space wrap style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => {
|
||||
setDate(e.target.value || today());
|
||||
setPage(1);
|
||||
}}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<Select
|
||||
value={resourceType}
|
||||
options={typeOptions}
|
||||
style={{ width: 120 }}
|
||||
onChange={(value) => {
|
||||
setResourceType(value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
value={keyword}
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索文件名"
|
||||
style={{ width: 220 }}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={() => {
|
||||
setPage(1);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
||||
</Space>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{items.length === 0 ? (
|
||||
<Empty description="暂无可复用的历史上传素材" style={{ padding: '48px 0' }} />
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14, minHeight: 260 }}>
|
||||
{items.map((item) => {
|
||||
const checked = checkedMap.has(item.id);
|
||||
const disabled = selectedIdSet.has(item.id);
|
||||
const preview = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => !disabled && toggle(item)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
borderRadius: 14,
|
||||
border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0',
|
||||
background: disabled ? '#f8fafc' : '#fff',
|
||||
opacity: disabled ? 0.55 : 1,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
overflow: 'hidden',
|
||||
boxShadow: checked ? '0 10px 24px rgba(139, 92, 246, 0.18)' : '0 6px 18px rgba(15,23,42,0.06)',
|
||||
}}
|
||||
>
|
||||
<div style={{ height: 112, background: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{item.resourceType === 'image' ? (
|
||||
<img src={preview} alt={item.fileName || item.id} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : item.resourceType === 'video' ? (
|
||||
<video src={preview} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{ width: 58, height: 58, borderRadius: 18, background: 'linear-gradient(135deg, #8b5cf6, #a78bfa)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 24 }}>
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: 10 }}>
|
||||
<Space size={6} style={{ marginBottom: 6 }}>
|
||||
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
|
||||
{item.durationSeconds ? <Tag style={{ margin: 0 }}>{Number(item.durationSeconds).toFixed(1)}s</Tag> : null}
|
||||
</Space>
|
||||
<Text ellipsis style={{ display: 'block', fontSize: 13, color: '#334155' }} title={item.fileName || item.id}>
|
||||
{item.fileName || item.id}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{item.moduleLabel || '普通上传'}</Text>
|
||||
</div>
|
||||
{checked && (
|
||||
<div style={{ position: 'absolute', top: 8, right: 8, width: 24, height: 24, borderRadius: 12, background: '#8b5cf6', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CheckOutlined />
|
||||
</div>
|
||||
)}
|
||||
{disabled && <div style={{ position: 'absolute', top: 8, right: 8 }}><Tag>已选</Tag></div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showSizeChanger
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
onChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextSize);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadResourceHistoryPicker;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as UploadResourceHistoryPicker } from './UploadResourceHistoryPicker';
|
||||
export { default as UploadResourceHistoryPanel } from './UploadResourceHistoryPanel';
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { PrivatePortraitAssetPicker } from '../components/privatePortrait';
|
||||
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset } from '../types';
|
||||
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset, UploadResourceHistoryItem } from '../types';
|
||||
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -77,8 +77,10 @@ interface MediaReference {
|
||||
source?: string;
|
||||
private_asset_id?: string;
|
||||
remote_asset_id?: string;
|
||||
upload_resource_id?: string;
|
||||
}
|
||||
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
original_prompt: string;
|
||||
@@ -1509,6 +1511,48 @@ const AIChatPage: React.FC = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
const normalizeUploadResourceHistoryItem = (item: UploadResourceHistoryItem): MediaReference | null => {
|
||||
const media = item.mediaReference;
|
||||
const refType = (media?.type || item.resourceType) as 'image' | 'video' | 'audio';
|
||||
const url = media?.url || item.resourceUrl || item.displayUrl || item.previewUrl || '';
|
||||
if (!url) {
|
||||
message.error(`${item.fileName || item.id} 缺少素材地址,不能用于 AI 创作`);
|
||||
return null;
|
||||
}
|
||||
if (refType === 'audio' && mediaType !== 'video') {
|
||||
message.error('仅视频模式支持添加音频素材');
|
||||
return null;
|
||||
}
|
||||
const duration = Number(media?.duration ?? item.durationSeconds);
|
||||
return {
|
||||
name: media?.name || item.fileName || item.id,
|
||||
type: refType,
|
||||
url,
|
||||
source: 'upload_resource',
|
||||
upload_resource_id: item.id,
|
||||
label: '',
|
||||
...((refType === 'video' || refType === 'audio') && Number.isFinite(duration) && duration > 0 ? { duration } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const handleUploadResourceHistorySelected = (items: UploadResourceHistoryItem[]) => {
|
||||
const normalized = items
|
||||
.map(normalizeUploadResourceHistoryItem)
|
||||
.filter(Boolean) as MediaReference[];
|
||||
if (!normalized.length) return;
|
||||
|
||||
const latestMedia = useAppStore.getState().currentMedia as MediaReference[];
|
||||
if (!validateMediaReferencesBeforeAdd(latestMedia, normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newList = [...latestMedia, ...normalized];
|
||||
const labels = generateMediaLabels(newList);
|
||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||
message.success(`已添加 ${normalized.length} 个历史上传素材参考`);
|
||||
};
|
||||
|
||||
const normalizePrivatePortraitAsset = (asset: PrivatePortraitSelectableAsset): MediaReference | null => {
|
||||
if (asset.assetType === 'Audio') {
|
||||
message.error('音频私域素材暂不支持用于 AI 创作');
|
||||
@@ -2532,9 +2576,9 @@ const AIChatPage: React.FC = () => {
|
||||
if (items.length > 0) {
|
||||
const item = items[0];
|
||||
const mediaRef: MediaReference = {
|
||||
name: item.name,
|
||||
name: item.fileName || '',
|
||||
type: 'image',
|
||||
url: item.url,
|
||||
url: item.resourceUrl || item.previewUrl || item.displayUrl || '',
|
||||
role: 'first_frame',
|
||||
label: '',
|
||||
};
|
||||
@@ -2622,9 +2666,9 @@ const AIChatPage: React.FC = () => {
|
||||
if (items.length > 0) {
|
||||
const item = items[0];
|
||||
const mediaRef: MediaReference = {
|
||||
name: item.name,
|
||||
name: item.fileName || '',
|
||||
type: 'image',
|
||||
url: item.url,
|
||||
url: item.resourceUrl || item.previewUrl || item.displayUrl || '',
|
||||
role: 'last_frame',
|
||||
label: '',
|
||||
};
|
||||
@@ -2688,16 +2732,7 @@ const AIChatPage: React.FC = () => {
|
||||
<UploadSelector
|
||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
|
||||
onLocalSelect={handleBatchUpload}
|
||||
onHistorySelect={(items) => {
|
||||
const newMedia = items.map((item: any) => ({
|
||||
name: item.name,
|
||||
type: item.type as 'image' | 'video' | 'audio',
|
||||
url: '',
|
||||
label: '',
|
||||
}));
|
||||
setCurrentMedia([...currentMedia, ...newMedia]);
|
||||
message.success(`成功添加${items.length}个历史记录`);
|
||||
}}
|
||||
onHistorySelect={handleUploadResourceHistorySelected}
|
||||
onPortraitLibrarySelect={(libraryType) => {
|
||||
openPrivatePortraitPicker(libraryType);
|
||||
}}
|
||||
@@ -2868,16 +2903,7 @@ const AIChatPage: React.FC = () => {
|
||||
<UploadSelector
|
||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
|
||||
onLocalSelect={handleBatchUpload}
|
||||
onHistorySelect={(items) => {
|
||||
const newMedia = items.map((item: any) => ({
|
||||
name: item.name,
|
||||
type: item.type as 'image' | 'video' | 'audio',
|
||||
url: '',
|
||||
label: '',
|
||||
}));
|
||||
setCurrentMedia([...currentMedia, ...newMedia]);
|
||||
message.success(`成功添加${items.length}个历史记录`);
|
||||
}}
|
||||
onHistorySelect={handleUploadResourceHistorySelected}
|
||||
onPortraitLibrarySelect={(libraryType) => {
|
||||
openPrivatePortraitPicker(libraryType);
|
||||
}}
|
||||
|
||||
@@ -1858,11 +1858,11 @@ const GeneratePage: React.FC = () => {
|
||||
});
|
||||
}}
|
||||
onHistorySelect={(items) => {
|
||||
items.forEach((item: any) => {
|
||||
items.forEach((item) => {
|
||||
setReferences(prev => [...prev, {
|
||||
url: '',
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
url: item.resourceUrl || item.previewUrl || item.displayUrl || '',
|
||||
type: item.resourceType,
|
||||
name: item.fileName || '',
|
||||
}]);
|
||||
});
|
||||
message.success(`成功添加${items.length}个历史记录`);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { PrivatePortraitLibraryPanel } from '../components/privatePortrait';
|
||||
import { UploadResourceHistoryPanel } from '../components/uploadResource';
|
||||
import { gethistory, gethistoryItems, getOAuthList, asyncBatchUploadMaterial, updateFilename, getUploadHistory, getAllOAuthAccountList, getOpenTypeAll, getPreTestList, getDefaultPreTest, deleteHistory, deleteResourcesMaterial } from '../api';
|
||||
|
||||
const { Search } = Input;
|
||||
@@ -52,8 +53,8 @@ const GeneratedRecord: React.FC = () => {
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialFilterType = searchParams.get('filterType') === 'private_portrait' ? 'private_portrait' : 'project';
|
||||
const [filterType, setFilterType] = useState<'project' | 'creation' | 'hot_opening_replicate' | 'shot_replicate' | 'private_portrait'>(initialFilterType);
|
||||
const initialFilterType = searchParams.get('filterType') === 'private_portrait' ? 'private_portrait' : searchParams.get('filterType') === 'upload_resource' ? 'upload_resource' : 'project';
|
||||
const [filterType, setFilterType] = useState<'project' | 'creation' | 'hot_opening_replicate' | 'shot_replicate' | 'private_portrait' | 'upload_resource'>(initialFilterType);
|
||||
const [filterMedia, setFilterMedia] = useState<'video' | 'image'>('video');
|
||||
const [recordlist, setRecordList] = useState<any[]>([]);
|
||||
const [Pagebreak, setPagebreak] = useState<any>({
|
||||
@@ -759,7 +760,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
});
|
||||
};
|
||||
const loadRecordList = () => {
|
||||
if (filterType === 'private_portrait') {
|
||||
if (filterType === 'private_portrait' || filterType === 'upload_resource') {
|
||||
setLoading(false);
|
||||
setRecordList([]);
|
||||
return;
|
||||
@@ -1033,9 +1034,29 @@ const GeneratedRecord: React.FC = () => {
|
||||
>
|
||||
私域素材库
|
||||
</Button>
|
||||
<Button
|
||||
type={filterType === 'upload_resource' ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setFilterType('upload_resource');
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: filterType === 'upload_resource'
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: '#f8f9fc',
|
||||
border: filterType === 'upload_resource' ? 'none' : '1px solid #e2e8f0',
|
||||
color: filterType === 'upload_resource' ? '#fff' : '#64748b',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
icon={<UploadOutlined />}
|
||||
>
|
||||
历史素材
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
{filterType !== 'private_portrait' && (
|
||||
{filterType !== 'private_portrait' && filterType !== 'upload_resource' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
{/* 多选模式按钮 */}
|
||||
{isSelectionMode ? (
|
||||
@@ -1120,6 +1141,8 @@ const GeneratedRecord: React.FC = () => {
|
||||
</div>
|
||||
{filterType === 'private_portrait' ? (
|
||||
<PrivatePortraitLibraryPanel />
|
||||
) : filterType === 'upload_resource' ? (
|
||||
<UploadResourceHistoryPanel />
|
||||
) : (
|
||||
<>
|
||||
{/* Second row filter: 视频 / 图片 */}
|
||||
|
||||
@@ -19,11 +19,21 @@ import {
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { uploadVideo, uploadImage, generateReplication, getReplicationList, getone, getReplicationDetail, deleteHotOpeningReplicationTask } from '../api';
|
||||
import { uploadHotOpeningVideo, uploadHotOpeningImage, generateReplication, getReplicationList, getone, getReplicationDetail, deleteHotOpeningReplicationTask } from '../api';
|
||||
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
|
||||
const buildAssetUrl = (url?: string): string => {
|
||||
if (!url) return '';
|
||||
if (/^https?:\/\//i.test(url) || url.startsWith('blob:')) return url;
|
||||
return `${API_BASE}${url}`;
|
||||
};
|
||||
|
||||
|
||||
const GenerateConver: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -51,8 +61,11 @@ const GenerateConver: React.FC = () => {
|
||||
// 文件状态
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const [videoResourceId, setVideoResourceId] = useState<string>('');
|
||||
const [videoDurationSeconds, setVideoDurationSeconds] = useState<number | null>(null);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imageUrl, setImageUrl] = useState<string>('');
|
||||
const [imageResourceId, setImageResourceId] = useState<string>('');
|
||||
const [videoUploading, setVideoUploading] = useState(false);
|
||||
const [imageUploading, setImageUploading] = useState(false);
|
||||
|
||||
@@ -223,9 +236,11 @@ const GenerateConver: React.FC = () => {
|
||||
// 调用上传接口
|
||||
setVideoUploading(true);
|
||||
try {
|
||||
const res = await uploadVideo(file);
|
||||
const res = await uploadHotOpeningVideo(file, video.duration);
|
||||
setVideoFile(file);
|
||||
setVideoUrl(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${res.url}`);
|
||||
setVideoUrl(res.url);
|
||||
setVideoResourceId(res.resource_id || '');
|
||||
setVideoDurationSeconds(video.duration);
|
||||
message.success('视频上传成功');
|
||||
resolve(false);
|
||||
} catch (error) {
|
||||
@@ -281,9 +296,10 @@ const GenerateConver: React.FC = () => {
|
||||
// 调用上传接口
|
||||
setImageUploading(true);
|
||||
try {
|
||||
const res = await uploadImage(file);
|
||||
const res = await uploadHotOpeningImage(file);
|
||||
setImageFile(file);
|
||||
setImageUrl(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${res.url}`);
|
||||
setImageUrl(res.url);
|
||||
setImageResourceId(res.resource_id || '');
|
||||
message.success('图片上传成功');
|
||||
resolve(false);
|
||||
} catch (error) {
|
||||
@@ -364,6 +380,9 @@ const GenerateConver: React.FC = () => {
|
||||
let params = {
|
||||
material_video_url: videoUrl,
|
||||
material_image_url: imageUrl,
|
||||
material_video_resource_id: videoResourceId || undefined,
|
||||
material_image_resource_id: imageResourceId || undefined,
|
||||
material_video_duration_seconds: videoDurationSeconds || undefined,
|
||||
source_project_name: originalProductName,
|
||||
target_project_name: ownProductName,
|
||||
core_content_point: productSellingPoints,
|
||||
@@ -381,7 +400,10 @@ const GenerateConver: React.FC = () => {
|
||||
|
||||
// 清空上传的媒体和文本
|
||||
setVideoUrl('');
|
||||
setVideoResourceId('');
|
||||
setVideoDurationSeconds(null);
|
||||
setImageUrl('');
|
||||
setImageResourceId('');
|
||||
setOriginalProductName('');
|
||||
setOwnProductName('');
|
||||
setProductSellingPoints('');
|
||||
@@ -819,7 +841,7 @@ const GenerateConver: React.FC = () => {
|
||||
{videoUrl ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<video
|
||||
src={videoUrl}
|
||||
src={buildAssetUrl(videoUrl)}
|
||||
controls
|
||||
style={{ width: '100%', borderRadius: 12, maxHeight: 200, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.08)' }}
|
||||
/>
|
||||
@@ -935,7 +957,7 @@ const GenerateConver: React.FC = () => {
|
||||
{imageUrl ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
src={buildAssetUrl(imageUrl)}
|
||||
alt="产品图片"
|
||||
style={{ width: '100%', borderRadius: 12, maxHeight: 200, objectFit: 'contain', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.08)' }}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Button, Drawer, Input, Modal, Popconfirm, Spin, Table, Tag, Tooltip, Upload, message } from 'antd';
|
||||
import { ArrowLeftOutlined, PlusOutlined, XOutlined } from '@ant-design/icons';
|
||||
import { createRemoveLens, deleteSegment, getShotReplicationDetail, reanalyzeSegment, Removelist, removeCreate, reanalyzeShotReplication, splitCustom, uploadImage } from '../api';
|
||||
|
||||
import {uploadShotReplicateImage, createRemoveLens, deleteSegment, getShotReplicationDetail, reanalyzeSegment, Removelist, removeCreate, reanalyzeShotReplication, splitCustom, uploadImage } from '../api';
|
||||
|
||||
import VideoTrimPicker from '../components/VideoTrimPicker';
|
||||
|
||||
const { TextArea } = Input;
|
||||
@@ -30,6 +32,7 @@ function RemoveInfo() {
|
||||
const [productName, setProductName] = useState('');
|
||||
const [productSellingPoint, setProductSellingPoint] = useState('');
|
||||
const [productImage, setProductImage] = useState('');
|
||||
const [productImageResourceId, setProductImageResourceId] = useState('');
|
||||
const [taskDetail, setTaskDetail] = useState<any>(null);
|
||||
const [tableData, setTableData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -235,18 +238,21 @@ function RemoveInfo() {
|
||||
setCurrentSegmentName('');
|
||||
setProductSellingPoint('');
|
||||
setProductImage('');
|
||||
setProductImageResourceId('');
|
||||
};
|
||||
|
||||
const handleProductImageChange: any = (info: any) => {
|
||||
if (info.fileList.length === 0) {
|
||||
setProductImage('');
|
||||
setProductImageResourceId('');
|
||||
}
|
||||
};
|
||||
|
||||
const beforeUploadProductImage = async (file: File) => {
|
||||
try {
|
||||
const uploadResult = await uploadImage(file);
|
||||
const uploadResult = await uploadShotReplicateImage(file);
|
||||
setProductImage(uploadResult.url);
|
||||
setProductImageResourceId(uploadResult.resource_id || '');
|
||||
message.success('图片上传成功');
|
||||
} catch {
|
||||
message.error('图片上传失败,请重试');
|
||||
@@ -274,11 +280,13 @@ function RemoveInfo() {
|
||||
|
||||
|
||||
const params = {
|
||||
target_project_name: productName.trim(),
|
||||
core_content_point: productSellingPoint.trim(),
|
||||
material_image_url: buildAssetUrl(productImage),
|
||||
idempotency_key: `replication_${Date.now()}`,
|
||||
};
|
||||
|
||||
target_project_name: productName.trim(),
|
||||
core_content_point: productSellingPoint.trim(),
|
||||
material_image_url: productImage,
|
||||
material_image_resource_id: productImageResourceId || undefined,
|
||||
idempotency_key: `replication_${Date.now()}`,
|
||||
};
|
||||
|
||||
|
||||
setLoading(true);
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useState, useRef, useCallback } from 'react';
|
||||
import { Button, Modal, Input, Table, Upload, Popconfirm, Tag, Space, message } from 'antd';
|
||||
import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { uploadVideo, createShotReplication, getShotReplicationList, deleteShotReplicationProject } from '../api';
|
||||
|
||||
import { uploadShotReplicateVideo, createShotReplication, getShotReplicationList, deleteShotReplicationProject } from '../api';
|
||||
|
||||
import bg1 from '../assets/bg1.png';
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
@@ -26,6 +28,7 @@ export default function VideoFrameExtractor() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const [videoResourceId, setVideoResourceId] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [productName, setProductName] = useState<string>('');
|
||||
@@ -45,6 +48,7 @@ export default function VideoFrameExtractor() {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
}
|
||||
setVideoUrl('');
|
||||
setVideoResourceId('');
|
||||
setError('');
|
||||
setVideoDuration(0);
|
||||
setProductName('');
|
||||
@@ -83,8 +87,9 @@ export default function VideoFrameExtractor() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const uploadResult = await uploadVideo(file);
|
||||
const uploadResult = await uploadShotReplicateVideo(file, duration);
|
||||
setVideoUrl(uploadResult.url);
|
||||
setVideoResourceId(uploadResult.resource_id || '');
|
||||
setError('');
|
||||
message.success('视频上传成功');
|
||||
} catch (err) {
|
||||
@@ -114,6 +119,7 @@ export default function VideoFrameExtractor() {
|
||||
const params = {
|
||||
video_url: videoUrl,
|
||||
video_duration_seconds: videoDuration,
|
||||
video_resource_id: videoResourceId || undefined,
|
||||
title: productName.trim(),
|
||||
idempotency_key: `shot_${Date.now()}`,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Button, Empty, Form, Input, InputNumber, message, Modal, Pagination, Radio, Select, Space, Table, Tabs, Tag, Tooltip, Typography,
|
||||
Button, Empty, Form, Input, InputNumber, message, Modal, Pagination, Radio, Select, Segmented, Space, Table, Tabs, Tag, Tooltip, Typography,
|
||||
} from 'antd';
|
||||
import { DatePicker } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -190,18 +190,20 @@ const TeamManagementPage: React.FC = () => {
|
||||
// ── Tab 3: 加入申请 ──
|
||||
const [requests, setRequests] = useState<TeamJoinRequest[]>([]);
|
||||
const [reqLoading, setReqLoading] = useState(false);
|
||||
const [reqStatusFilter, setReqStatusFilter] = useState<string>('pending');
|
||||
const [activeTab, setActiveTab] = useState('members');
|
||||
const initialNoticeShownRef = useRef(false);
|
||||
const lastRequestCountRef = useRef(0);
|
||||
|
||||
const loadRequests = useCallback(async (isInitial = false) => {
|
||||
const loadRequests = useCallback(async (status: string, isInitial = false) => {
|
||||
setReqLoading(true);
|
||||
try {
|
||||
const data = await getPendingJoinRequests();
|
||||
const data = await getPendingJoinRequests(status);
|
||||
const currentCount = data?.length || 0;
|
||||
setRequests(data || []);
|
||||
|
||||
if (currentCount > 0) {
|
||||
// 只有待处理状态才显示弹窗通知
|
||||
if (status === 'pending' && currentCount > 0) {
|
||||
if (isInitial && !initialNoticeShownRef.current) {
|
||||
initialNoticeShownRef.current = true;
|
||||
Modal.confirm({
|
||||
@@ -231,7 +233,9 @@ const TeamManagementPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
lastRequestCountRef.current = currentCount;
|
||||
if (status === 'pending') {
|
||||
lastRequestCountRef.current = currentCount;
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载申请失败');
|
||||
} finally {
|
||||
@@ -240,13 +244,13 @@ const TeamManagementPage: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadRequests(true);
|
||||
loadRequests('pending', true);
|
||||
}, [loadRequests]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!team) return;
|
||||
const interval = setInterval(() => {
|
||||
loadRequests(false);
|
||||
loadRequests('pending', false);
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [team, loadRequests]);
|
||||
@@ -255,7 +259,7 @@ const TeamManagementPage: React.FC = () => {
|
||||
try {
|
||||
await handleJoinRequest(requestId, action, note);
|
||||
message.success(action === 'approve' ? '已通过' : '已拒绝');
|
||||
loadRequests();
|
||||
loadRequests(reqStatusFilter);
|
||||
loadMembers();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
@@ -397,33 +401,49 @@ const TeamManagementPage: React.FC = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const renderStatusTag = (status: string) => {
|
||||
const map: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'orange', text: '待处理' },
|
||||
approved: { color: 'green', text: '已通过' },
|
||||
rejected: { color: 'red', text: '已拒绝' },
|
||||
};
|
||||
const info = map[status] || { color: 'default', text: status };
|
||||
return <Tag color={info.color}>{info.text}</Tag>;
|
||||
};
|
||||
|
||||
const reqColumns = [
|
||||
{ title: '申请人', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
|
||||
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
|
||||
{ title: '状态', dataIndex: 'status', width: 100, render: (v: string) => renderStatusTag(v) },
|
||||
{ title: '申请时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
|
||||
{ title: '处理时间', dataIndex: 'handledAt', width: 170, render: (v: string) => v ? formatDateTime(v) : '-' },
|
||||
{ title: '备注', dataIndex: 'note', width: 180, ellipsis: true, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '操作', key: 'action', width: 160,
|
||||
render: (_: any, r: TeamJoinRequest) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" type="link" icon={<CheckOutlined />} style={{ color: '#16a34a', padding: 0 }} onClick={() => handleRequest(r.id, 'approve')}>通过</Button>
|
||||
<Button size="small" type="link" icon={<CloseOutlined />} danger style={{ padding: 0 }} onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '拒绝申请',
|
||||
content: (
|
||||
<Form layout="vertical" style={{ marginTop: 12 }}>
|
||||
<Form.Item name="note" label="拒绝原因(可选)">
|
||||
<Input.TextArea rows={2} placeholder="选填" id="reject-note-input" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
onOk: () => {
|
||||
const note = (document.getElementById('reject-note-input') as HTMLTextAreaElement)?.value || undefined;
|
||||
handleRequest(r.id, 'reject', note);
|
||||
},
|
||||
});
|
||||
}}>拒绝</Button>
|
||||
</Space>
|
||||
),
|
||||
render: (_: any, r: TeamJoinRequest) => {
|
||||
if (r.status !== 'pending') return <span style={{ color: '#94a3b8', fontSize: 12 }}>已处理</span>;
|
||||
return (
|
||||
<Space size={4}>
|
||||
<Button size="small" type="link" icon={<CheckOutlined />} style={{ color: '#16a34a', padding: 0 }} onClick={() => handleRequest(r.id, 'approve')}>通过</Button>
|
||||
<Button size="small" type="link" icon={<CloseOutlined />} danger style={{ padding: 0 }} onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '拒绝申请',
|
||||
content: (
|
||||
<Form layout="vertical" style={{ marginTop: 12 }}>
|
||||
<Form.Item name="note" label="拒绝原因(可选)">
|
||||
<Input.TextArea rows={2} placeholder="选填" id="reject-note-input" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
onOk: () => {
|
||||
const note = (document.getElementById('reject-note-input') as HTMLTextAreaElement)?.value || undefined;
|
||||
handleRequest(r.id, 'reject', note);
|
||||
},
|
||||
});
|
||||
}}>拒绝</Button>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -568,20 +588,47 @@ const TeamManagementPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
key: 'requests',
|
||||
label: <Space><HistoryOutlined />加入申请{requests.length > 0 && <Tag color="red">{requests.length}</Tag>}</Space>,
|
||||
label: <Space><HistoryOutlined />加入申请{requests.length > 0 && reqStatusFilter === 'pending' && <Tag color="red">{requests.length}</Tag>}</Space>,
|
||||
children: (
|
||||
<div style={tableWrapper}>
|
||||
<Table
|
||||
columns={reqColumns}
|
||||
dataSource={requests}
|
||||
rowKey="id"
|
||||
loading={reqLoading}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
scroll={{ x: 600 }}
|
||||
locale={{ emptyText: <Empty description="暂无待审批申请" /> }}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center' }}>
|
||||
<Segmented
|
||||
value={reqStatusFilter}
|
||||
onChange={(v) => {
|
||||
setReqStatusFilter(v as string);
|
||||
loadRequests(v as string);
|
||||
}}
|
||||
options={[
|
||||
{ label: '待处理', value: 'pending' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' },
|
||||
{ label: '全部', value: '' },
|
||||
]}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: 3,
|
||||
background: '#f8fafc',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
'--ant-segmented-item-selected-bg': '#6366f1',
|
||||
'--ant-segmented-item-selected-color': '#ffffff',
|
||||
} as React.CSSProperties}
|
||||
size="middle"
|
||||
/>
|
||||
</div>
|
||||
<div style={tableWrapper}>
|
||||
<Table
|
||||
columns={reqColumns}
|
||||
dataSource={requests}
|
||||
rowKey="id"
|
||||
loading={reqLoading}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
scroll={{ x: 1000 }}
|
||||
locale={{ emptyText: <Empty description={reqStatusFilter === 'pending' ? '暂无待审批申请' : '暂无记录'} /> }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -599,7 +646,7 @@ const TeamManagementPage: React.FC = () => {
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>{team.name}</Typography.Title>
|
||||
<Typography.Text type="secondary">代码: {team.code || '-'} 成员: {team.memberCount} 人</Typography.Text>
|
||||
</div>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { loadTeam(); loadMembers(); loadInvitations(); loadRequests(); loadCreditRecords(); }}>刷新</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { loadTeam(); loadMembers(); loadInvitations(); loadRequests(reqStatusFilter); loadCreditRecords(); }}>刷新</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Text type="secondary">无法获取团队信息</Typography.Text>
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface TeamJoinRequest {
|
||||
status: string;
|
||||
note?: string | null;
|
||||
createdAt?: string | null;
|
||||
handledAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ManagedTeam {
|
||||
@@ -138,8 +139,10 @@ export interface MediaReference {
|
||||
providerUrl?: string;
|
||||
displayUrl?: string;
|
||||
previewUrl?: string;
|
||||
upload_resource_id?: string;
|
||||
}
|
||||
|
||||
|
||||
export interface GenerationRecord {
|
||||
id: string;
|
||||
items:[],
|
||||
@@ -385,3 +388,69 @@ export interface PrivatePortraitSelectableAssetListOut {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── UploadResource History Types ──────────────────────────
|
||||
export type UploadResourceType = 'image' | 'video' | 'audio';
|
||||
|
||||
export interface UploadResourceMediaReference {
|
||||
name?: string | null;
|
||||
type: UploadResourceType;
|
||||
url: string;
|
||||
label?: string | null;
|
||||
duration?: number | null;
|
||||
source: 'upload_resource';
|
||||
uploadResourceId: string;
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryItem {
|
||||
id: string;
|
||||
sourceType: 'upload_resource';
|
||||
historySource: 'upload_resource';
|
||||
historySourceLabel: string;
|
||||
module: string;
|
||||
moduleLabel: string;
|
||||
resourceType: UploadResourceType;
|
||||
resourceTypeLabel: string;
|
||||
resourceUrl: string;
|
||||
displayUrl: string;
|
||||
previewUrl: string;
|
||||
imageUrl?: string | null;
|
||||
videoUrl?: string | null;
|
||||
audioUrl?: string | null;
|
||||
fileName?: string | null;
|
||||
fileExt?: string | null;
|
||||
mimeType?: string | null;
|
||||
fileSizeBytes: number;
|
||||
durationSeconds?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
bindStatus: string;
|
||||
deletePolicy: string;
|
||||
deletable: boolean;
|
||||
mediaReference: UploadResourceMediaReference;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryDayGroup {
|
||||
generatedDate: string;
|
||||
total: number;
|
||||
page: number;
|
||||
items: UploadResourceHistoryItem[];
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryGrouped {
|
||||
totalDays: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
groups: UploadResourceHistoryDayGroup[];
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryDayItems {
|
||||
generatedDate: string;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
items: UploadResourceHistoryItem[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user