diff --git a/video-gen-admin/src/api/index.ts b/video-gen-admin/src/api/index.ts index ede895ca..fea31823 100644 --- a/video-gen-admin/src/api/index.ts +++ b/video-gen-admin/src/api/index.ts @@ -448,7 +448,7 @@ export async function deleteMenuConfig(id: string): Promise { // ── User Creation ─────────────────────────────────────── -export async function createUser(data: { username?: string; password: string; email?: string; phone?: string; credits: number; user_type: string; frontend_user_kind?: string; allowed_menus?: string[] | null; private_portrait_image_limit?: number }): Promise { +export async function createUser(data: { username?: string; password: string; email?: string; phone?: string; credits: number; user_type: string; frontend_user_kind?: string; allowed_menus?: string[] | null; private_portrait_asset_limit?: number }): Promise { return api.post('/admin/users', data); } @@ -1017,22 +1017,25 @@ export async function getPreTestTemplateList(params: PreTestTemplateListParams): } // ── Private Portrait Admin ──────────────────────────────── -export async function adminGetPrivatePortraitProjects(params: { userId?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise { +export async function adminGetPrivatePortraitProjects(params: { userId?: string; libraryType?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise { const query = new URLSearchParams(); query.set('page', String(params.page || 1)); query.set('page_size', String(params.pageSize || 20)); if (params.userId) query.set('user_id', params.userId); + if (params.libraryType) query.set('library_type', params.libraryType); if (params.keyword) query.set('keyword', params.keyword); if (params.status) query.set('status', params.status); return api.get(`/admin/private-portrait/projects?${query.toString()}`); } -export async function adminGetPrivatePortraitAssets(params: { userId?: string; projectId?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise { +export async function adminGetPrivatePortraitAssets(params: { userId?: string; projectId?: string; libraryType?: string; assetType?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise { const query = new URLSearchParams(); query.set('page', String(params.page || 1)); query.set('page_size', String(params.pageSize || 20)); if (params.userId) query.set('user_id', params.userId); if (params.projectId) query.set('project_id', params.projectId); + if (params.libraryType) query.set('library_type', params.libraryType); + if (params.assetType) query.set('asset_type', params.assetType); if (params.keyword) query.set('keyword', params.keyword); if (params.status) query.set('status', params.status); return api.get(`/admin/private-portrait/assets?${query.toString()}`); @@ -1044,5 +1047,5 @@ export async function adminGetPrivatePortraitConfig(userId: string): Promise { - return api.put(`/admin/private-portrait/users/${userId}/config`, { private_portrait_image_limit: limit }); + return api.put(`/admin/private-portrait/users/${userId}/config`, { private_portrait_asset_limit: limit }); } diff --git a/video-gen-admin/src/pages/AdminPrivatePortraitProjects.tsx b/video-gen-admin/src/pages/AdminPrivatePortraitProjects.tsx index 64caa200..cf616a8a 100644 --- a/video-gen-admin/src/pages/AdminPrivatePortraitProjects.tsx +++ b/video-gen-admin/src/pages/AdminPrivatePortraitProjects.tsx @@ -1,20 +1,102 @@ -import React, { useEffect, useState } from 'react'; -import { Button, Card, Input, Space, Table, Tag, Typography, message } from 'antd'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Button, Card, Image, Input, Select, Space, Statistic, Table, Tag, Tooltip, Typography, message } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'; +import { CopyOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; import { adminGetPrivatePortraitAssets, adminGetPrivatePortraitProjects } from '../api'; import type { PrivatePortraitAsset, PrivatePortraitProject } from '../types'; +import { formatDate } from '../utils/formatDate'; const { Title, Text } = Typography; +const PAGE_SIZE = 20; + +const LIBRARY_OPTIONS = [ + { label: '全部素材库', value: '' }, + { label: '真人认证', value: 'real_person' }, + { label: '虚拟人像', value: 'aigc_virtual' }, +]; + +const ASSET_TYPE_OPTIONS = [ + { label: '全部类型', value: '' }, + { label: '图片', value: 'Image' }, + { label: '视频', value: 'Video' }, +]; + +const PROJECT_STATUS_OPTIONS = [ + { label: '全部项目状态', value: '' }, + { label: '可用', value: 'active' }, + { label: '认证中', value: 'validating' }, + { label: '认证失败', value: 'validate_failed' }, + { label: '创建远端组中', value: 'creating_remote_group' }, + { label: '创建远端组失败', value: 'create_group_failed' }, + { label: '已删除', value: 'deleted' }, +]; + +const ASSET_STATUS_OPTIONS = [ + { label: '全部素材状态', value: '' }, + { label: '创建中', value: 'creating' }, + { label: '处理中', value: 'Processing' }, + { label: '可用', value: 'Active' }, + { label: '失败', value: 'Failed' }, + { label: '本地已删', value: 'local_deleted' }, + { label: '远端已删', value: 'remote_deleted' }, + { label: '删除失败', value: 'delete_failed' }, +]; + +const libraryLabel = (value?: string | null) => { + if (value === 'real_person') return '真人认证'; + if (value === 'aigc_virtual') return '虚拟人像'; + return value || '-'; +}; + +const libraryColor = (value?: string | null) => { + if (value === 'real_person') return 'purple'; + if (value === 'aigc_virtual') return 'cyan'; + return 'default'; +}; + const statusColor = (status?: string) => { if (status === 'Active' || status === 'active') return 'green'; - if (status === 'Processing') return 'blue'; - if (status === 'Failed' || status === 'failed' || status === 'delete_failed') return 'red'; + if (status === 'Processing' || status === 'creating_remote_group' || status === 'validating' || status === 'creating') return 'blue'; + if (status === 'Failed' || status === 'failed' || status === 'delete_failed' || status === 'validate_failed' || status === 'create_group_failed') return 'red'; if (status?.includes('deleted')) return 'default'; return 'default'; }; +const assetTypeLabel = (value?: string | null) => { + if (value === 'Image') return '图片'; + if (value === 'Video') return '视频'; + return value || '-'; +}; + +const shortId = (value?: string | null, keep = 10) => { + if (!value) return '-'; + if (value.length <= keep * 2 + 3) return value; + return `${value.slice(0, keep)}...${value.slice(-keep)}`; +}; + +const copyText = async (value?: string | null) => { + if (!value) return; + await navigator.clipboard?.writeText(value); + message.success('已复制'); +}; + +const PreviewCell: React.FC<{ asset: PrivatePortraitAsset }> = ({ asset }) => { + const url = asset.displayUrl || asset.previewUrl || asset.remoteUrl || asset.sourceUrl; + const cover = asset.videoCoverUrl || asset.previewUrl || asset.displayUrl; + if (asset.assetType === 'Video') { + return ( +
+ {cover ? : } + {url ? : null} + +
+ ); + } + if (url) return ; + return
; +}; + const AdminPrivatePortraitProjects: React.FC = () => { const [projects, setProjects] = useState([]); const [assets, setAssets] = useState([]); @@ -23,18 +105,36 @@ const AdminPrivatePortraitProjects: React.FC = () => { const [loadingProjects, setLoadingProjects] = useState(false); const [loadingAssets, setLoadingAssets] = useState(false); const [keyword, setKeyword] = useState(''); + const [libraryType, setLibraryType] = useState(''); + const [assetType, setAssetType] = useState(''); + const [projectStatus, setProjectStatus] = useState(''); + const [assetStatus, setAssetStatus] = useState(''); const [selectedProjectId, setSelectedProjectId] = useState(); const [projectPage, setProjectPage] = useState(1); const [assetPage, setAssetPage] = useState(1); + const filters = useMemo(() => ({ + keyword: keyword.trim() || undefined, + libraryType: libraryType || undefined, + assetType: assetType || undefined, + projectStatus: projectStatus || undefined, + assetStatus: assetStatus || undefined, + }), [keyword, libraryType, assetType, projectStatus, assetStatus]); + const loadProjects = async () => { setLoadingProjects(true); try { - const res = await adminGetPrivatePortraitProjects({ keyword: keyword.trim() || undefined, page: projectPage, pageSize: 20 }); + const res = await adminGetPrivatePortraitProjects({ + keyword: filters.keyword, + libraryType: filters.libraryType, + status: filters.projectStatus, + page: projectPage, + pageSize: PAGE_SIZE, + }); setProjects(res.items || []); setProjectTotal(res.total || 0); } catch (err: any) { - message.error(err?.message || '加载真人素材项目失败'); + message.error(err?.message || '加载私域人像素材项目失败'); } finally { setLoadingProjects(false); } @@ -43,88 +143,111 @@ const AdminPrivatePortraitProjects: React.FC = () => { const loadAssets = async () => { setLoadingAssets(true); try { - const res = await adminGetPrivatePortraitAssets({ projectId: selectedProjectId, keyword: keyword.trim() || undefined, page: assetPage, pageSize: 20 }); + const res = await adminGetPrivatePortraitAssets({ + projectId: selectedProjectId, + keyword: filters.keyword, + libraryType: filters.libraryType, + assetType: filters.assetType, + status: filters.assetStatus, + page: assetPage, + pageSize: PAGE_SIZE, + }); setAssets(res.items || []); setAssetTotal(res.total || 0); } catch (err: any) { - message.error(err?.message || '加载真人素材失败'); + message.error(err?.message || '加载私域人像素材失败'); } finally { setLoadingAssets(false); } }; - useEffect(() => { loadProjects(); }, [projectPage]); - useEffect(() => { loadAssets(); }, [selectedProjectId, assetPage]); + useEffect(() => { loadProjects(); }, [projectPage, filters.libraryType, filters.projectStatus]); + useEffect(() => { loadAssets(); }, [selectedProjectId, assetPage, filters.libraryType, filters.assetType, filters.assetStatus]); + + const searchAll = () => { + setProjectPage(1); + setAssetPage(1); + setTimeout(() => { loadProjects(); loadAssets(); }, 0); + }; + + const resetFilters = () => { + setKeyword(''); + setLibraryType(''); + setAssetType(''); + setProjectStatus(''); + setAssetStatus(''); + setSelectedProjectId(undefined); + setProjectPage(1); + setAssetPage(1); + }; + + const projectSummary = useMemo(() => { + return projects.reduce((acc, item) => { + acc.asset += item.assetCount || 0; + acc.image += item.imageAssetCount || 0; + acc.video += item.videoAssetCount || 0; + acc.active += item.activeAssetCount || 0; + return acc; + }, { asset: 0, image: 0, video: 0, active: 0 }); + }, [projects]); const projectColumns: ColumnsType = [ - { title: '项目名称', dataIndex: 'name', width: 180, render: (v, r) => }, - { title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => {v || '-'} }, - { title: '状态', dataIndex: 'status', width: 100, render: (v) => {v} }, - { title: 'ProjectName', dataIndex: 'remoteProjectName', width: 240, render: (v) => {v || '-'} }, - { title: '素材数', dataIndex: 'assetCount', width: 100 }, - { title: 'Active', dataIndex: 'activeAssetCount', width: 100 }, - { title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v) => v || '-' }, + { title: '项目名称', dataIndex: 'name', width: 200, fixed: 'left', render: (v, r) => }, + { title: '类型', dataIndex: 'libraryType', width: 110, render: (v) => {libraryLabel(v)} }, + { title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => {v || '-'} }, + { title: '状态', dataIndex: 'status', width: 130, render: (v) => {v} }, + { title: '素材', width: 170, render: (_, r) => 总 {r.assetCount || 0}图 {r.imageAssetCount || 0}视 {r.videoAssetCount || 0} }, + { title: 'Active', dataIndex: 'activeAssetCount', width: 90 }, + { title: 'ProjectName', dataIndex: 'remoteProjectName', width: 180, render: (v) => {v || '-'} }, + { title: '创建时间', dataIndex: 'createdAt', width: 170, render: formatDate }, + { title: '更新时间', dataIndex: 'updatedAt', width: 170, render: formatDate }, ]; const assetColumns: ColumnsType = [ - { title: '素材', dataIndex: 'name', width: 180, render: (v, r) => v || r.remoteAssetId || '-' }, - { title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => {v || '-'} }, - { title: '本地项目', dataIndex: 'projectName', width: 160 }, - { title: 'AssetId', dataIndex: 'remoteAssetId', width: 240, render: (v) => {v} }, - { title: 'GroupId', dataIndex: 'remoteGroupId', width: 240, render: (v) => {v} }, - { title: 'ProjectName', dataIndex: 'remoteProjectName', width: 240, render: (v) => {v || '-'} }, - { title: '类型', dataIndex: 'assetType', width: 90 }, - { title: '状态', dataIndex: 'status', width: 110, render: (v) => {v} }, - { title: '错误', dataIndex: 'errorMessage', width: 240, ellipsis: true, render: (v) => v || '-' }, - { title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v) => v || '-' }, + { title: '预览', width: 100, fixed: 'left', render: (_, r) => }, + { title: '素材名称', dataIndex: 'name', width: 180, render: (v, r) =>
{v || '未命名素材'}
{assetTypeLabel(r.assetType)}
}, + { title: '库类型', dataIndex: 'libraryType', width: 110, render: (v) => {libraryLabel(v)} }, + { title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => {v || '-'} }, + { title: '本地项目', dataIndex: 'projectName', width: 160, render: (v) => v || '-' }, + { title: '状态', dataIndex: 'status', width: 120, render: (v) => {v} }, + { title: 'AssetId', dataIndex: 'remoteAssetId', width: 190, render: (v) => {shortId(v)}{v ? + - - + + + + + + + + + { setProjectStatus(v); setProjectPage(1); }} options={PROJECT_STATUS_OPTIONS} style={{ width: 180 }} />}> +
- setSelectedProjectId(undefined)}>查看全部 : null} - > -
+
); diff --git a/video-gen-admin/src/pages/AdminUsers.tsx b/video-gen-admin/src/pages/AdminUsers.tsx index 1bbefd5c..29d4e20e 100644 --- a/video-gen-admin/src/pages/AdminUsers.tsx +++ b/video-gen-admin/src/pages/AdminUsers.tsx @@ -189,7 +189,7 @@ const AdminUsers: React.FC = () => { credits: values.credits || 0, user_type: userType, frontend_user_kind: values.frontend_user_kind || 'external', - private_portrait_image_limit: userType === 'frontend' ? Number(values.private_portrait_image_limit ?? 5) : 0, + private_portrait_asset_limit: userType === 'frontend' ? Number(values.private_portrait_asset_limit ?? 5) : 0, }); message.success('用户创建成功'); setCreateModal(false); @@ -276,13 +276,13 @@ const AdminUsers: React.FC = () => { const openPortraitModal = async (user: AdminUser) => { setPortraitLoading(true); setPortraitModal({ open: true, user, config: null }); - portraitForm.setFieldsValue({ privatePortraitImageLimit: user.privatePortraitImageLimit ?? 5 }); + portraitForm.setFieldsValue({ privatePortraitAssetLimit: user.privatePortraitAssetLimit ?? 5 }); try { const config = await adminGetPrivatePortraitConfig(user.id); - portraitForm.setFieldsValue({ privatePortraitImageLimit: config.imageLimit }); + portraitForm.setFieldsValue({ privatePortraitAssetLimit: config.assetLimit }); setPortraitModal({ open: true, user, config }); } catch (e: any) { - message.error(e?.message || '加载真人素材库配置失败'); + message.error(e?.message || '加载私域人像素材库配置失败'); setPortraitModal({ open: false, user: null, config: null }); } finally { setPortraitLoading(false); @@ -294,16 +294,16 @@ const AdminUsers: React.FC = () => { if (!user) return; try { const values = await portraitForm.validateFields(); - const limit = Number(values.privatePortraitImageLimit ?? 0); + const limit = Number(values.privatePortraitAssetLimit ?? 0); setPortraitSaving(true); const config = await adminUpdatePrivatePortraitConfig(user.id, limit); - message.success(limit > 0 ? `真人素材库已开启,限制 ${limit} 张` : '真人素材库已关闭'); + message.success(limit > 0 ? `私域人像素材库已开启,限制 ${limit} 个` : '私域人像素材库已关闭'); setPortraitModal({ open: false, user: null, config }); portraitForm.resetFields(); load(); } catch (e: any) { if (e?.errorFields) return; - message.error(e?.message || '保存真人素材库配置失败'); + message.error(e?.message || '保存私域人像素材库配置失败'); } finally { setPortraitSaving(false); } @@ -424,10 +424,10 @@ const AdminUsers: React.FC = () => { render: (v: string | null | undefined) => v ? {v} : 未分配, }] : []), ...(!isAdminTab ? [{ - title: '真人素材库', dataIndex: 'privatePortraitImageLimit', width: 150, + title: '私域人像素材库', dataIndex: 'privatePortraitAssetLimit', width: 150, render: (v: number) => { const limit = Number(v || 0); - return limit > 0 ? 开启:{limit} 张 : 未开启; + return limit > 0 ? 开启:{limit} 个 : 未开启; }, }] : []), ...(!isAdminTab ? [{ @@ -493,7 +493,7 @@ const AdminUsers: React.FC = () => { {!isAdminTab && ( )} {!isAdminTab && ( @@ -784,7 +784,7 @@ const AdminUsers: React.FC = () => { 真人素材库设置 - {portraitModal.user?.username}} + title={私域人像素材库设置 - {portraitModal.user?.username}} open={portraitModal.open} confirmLoading={portraitSaving} onOk={handleSavePortraitConfig} @@ -797,17 +797,17 @@ const AdminUsers: React.FC = () => { 当前状态:{portraitModal.config?.enabled ? 已开启 : 未开启} - 已用:{portraitModal.config?.usedImageCount ?? '-'} 张; - 剩余:{portraitModal.config?.enabled ? portraitModal.config.remainingImageCount : 0} 张 + 已用:{portraitModal.config?.usedAssetCount ?? '-'} 个; + 剩余:{portraitModal.config?.enabled ? portraitModal.config.remainingAssetCount : 0} 个
@@ -857,11 +857,11 @@ const AdminUsers: React.FC = () => { )} {createType === 'frontend' && ( diff --git a/video-gen-admin/src/types/index.ts b/video-gen-admin/src/types/index.ts index c65d0fff..5e4df066 100644 --- a/video-gen-admin/src/types/index.ts +++ b/video-gen-admin/src/types/index.ts @@ -8,7 +8,7 @@ export interface User { userType: string; allowedMenus?: string[] | null; resourceCapacity?: ResourceCapacityUsage | null; - privatePortraitImageLimit: number; + privatePortraitAssetLimit: number; } export interface CreditRecord { @@ -194,7 +194,7 @@ export interface AdminUser { lastLoginAt?: string; allowedMenus?: string[] | null; resourceCapacity?: ResourceCapacityUsage | null; - privatePortraitImageLimit: number; + privatePortraitAssetLimit: number; } export interface AdminStats { @@ -1125,14 +1125,20 @@ export interface HomeMaterialTextWatermarkPreviewResponse { export interface PrivatePortraitConfig { enabled: boolean; - imageLimit: number; - usedImageCount: number; - remainingImageCount: number; + assetLimit: number; + usedAssetCount: number; + remainingAssetCount: number; + supportedAssetTypes?: string[]; + unsupportedAssetTypes?: string[]; + imageLimit?: number; + usedImageCount?: number; + remainingImageCount?: number; } export interface PrivatePortraitProject { id: string; userId?: string | null; + libraryType: string; name: string; nameSlug?: string | null; remoteProjectName?: string | null; @@ -1140,7 +1146,11 @@ export interface PrivatePortraitProject { status: string; assetGroupCount: number; assetCount: number; + imageAssetCount?: number; + videoAssetCount?: number; activeAssetCount: number; + activeImageAssetCount?: number; + activeVideoAssetCount?: number; lastUsedAt?: string | null; createdAt?: string | null; updatedAt?: string | null; @@ -1174,6 +1184,7 @@ export interface PrivatePortraitAsset { projectId: string; projectName?: string | null; groupId: string; + libraryType: string; remoteGroupId: string; remoteAssetId?: string | null; remoteProjectName?: string | null; @@ -1181,7 +1192,13 @@ export interface PrivatePortraitAsset { name?: string | null; sourceUrl: string; previewUrl?: string | null; + displayUrl?: string | null; + providerUrl?: string | null; remoteUrl?: string | null; + videoDuration?: number | null; + videoCoverUrl?: string | null; + fileSize?: number | null; + mimeType?: string | null; status: string; pollCount: number; remoteDeleteStatus: string; @@ -1201,9 +1218,14 @@ export interface PrivatePortraitSelectableAsset { id: string; projectId: string; projectName: string; + libraryType: string; name?: string | null; assetType: string; previewUrl?: string | null; + displayUrl?: string | null; + providerUrl?: string | null; + videoDuration?: number | null; + videoCoverUrl?: string | null; status: string; createdAt?: string | null; } diff --git a/video-gen-api/app/api/admin/private_portrait.py b/video-gen-api/app/api/admin/private_portrait.py index 7b640c4e..0d97e2ad 100644 --- a/video-gen-api/app/api/admin/private_portrait.py +++ b/video-gen-api/app/api/admin/private_portrait.py @@ -10,127 +10,59 @@ from app.dependencies import get_admin_user, get_db from app.models.user import User from app.schemas.private_portrait import ( PrivatePortraitAdminConfigUpdate, + PrivatePortraitAdminStatsOut, PrivatePortraitAssetListOut, PrivatePortraitConfigOut, PrivatePortraitProjectListOut, ) from app.services.operation_log import log_operation -from app.services.private_portrait.asset_service import ( - asset_to_out, - get_user_private_portrait_config, - list_assets, - set_user_private_portrait_limit, -) -from app.services.private_portrait.project_service import list_projects, project_to_out +from app.services.private_portrait.admin.asset_query_service import admin_list_assets +from app.services.private_portrait.admin.project_query_service import admin_list_projects +from app.services.private_portrait.admin.stats_query_service import admin_get_private_portrait_stats +from app.services.private_portrait.quota_service import get_user_private_portrait_config, set_user_private_portrait_limit -router = APIRouter(prefix="/admin/private-portrait", tags=["admin-private-portrait"]) +router = APIRouter(prefix="/admin/private-portrait", tags=["管理后台-私域人像素材库"]) def _json_detail(data: dict[str, Any]) -> str: return json.dumps(data, ensure_ascii=False, default=str) -@router.get("/users/{user_id}/config", response_model=PrivatePortraitConfigOut) -async def admin_get_private_portrait_config( - user_id: str, - admin: User = Depends(get_admin_user), - db: AsyncSession = Depends(get_db), -): +@router.get("/users/{user_id}/config", response_model=PrivatePortraitConfigOut, summary="管理后台:查看用户私域人像素材额度") +async def admin_get_private_portrait_config(user_id: str, admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)): config = await get_user_private_portrait_config(db, user_id=user_id) - await log_operation( - db, - admin.id, - admin.username, - "查看真人素材库配置", - "GET", - f"/admin/private-portrait/users/{user_id}/config", - detail=_json_detail({"target_user_id": user_id, "config": config.model_dump(mode="json")}), - ) + await log_operation(db, admin.id, admin.username, "查看私域人像素材库配置", "GET", f"/admin/private-portrait/users/{user_id}/config", detail=_json_detail({"target_user_id": user_id, "config": config.model_dump(mode="json")})) return config -@router.put("/users/{user_id}/config", response_model=PrivatePortraitConfigOut) -async def admin_update_private_portrait_config( - user_id: str, - payload: PrivatePortraitAdminConfigUpdate, - admin: User = Depends(get_admin_user), - db: AsyncSession = Depends(get_db), -): +@router.put("/users/{user_id}/config", response_model=PrivatePortraitConfigOut, summary="管理后台:设置用户私域人像素材总量") +async def admin_update_private_portrait_config(user_id: str, payload: PrivatePortraitAdminConfigUpdate, admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)): before = await get_user_private_portrait_config(db, user_id=user_id) - await set_user_private_portrait_limit(db, user_id=user_id, limit=payload.private_portrait_image_limit) + await set_user_private_portrait_limit(db, user_id=user_id, limit=payload.private_portrait_asset_limit) after = await get_user_private_portrait_config(db, user_id=user_id) - await log_operation( - db, - admin.id, - admin.username, - f"设置真人素材库数量限制:{before.image_limit} -> {after.image_limit}", - "PUT", - f"/admin/private-portrait/users/{user_id}/config", - detail=_json_detail( - { - "target_user_id": user_id, - "before": before.model_dump(mode="json"), - "after": after.model_dump(mode="json"), - } - ), - ) + await log_operation(db, admin.id, admin.username, f"设置私域人像素材总量限制:{before.asset_limit} -> {after.asset_limit}", "PUT", f"/admin/private-portrait/users/{user_id}/config", detail=_json_detail({"target_user_id": user_id, "before": before.model_dump(mode="json"), "after": after.model_dump(mode="json")})) + await db.commit() return after -@router.get("/projects", response_model=PrivatePortraitProjectListOut) -async def admin_list_private_portrait_projects( - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - user_id: str | None = Query(None), - keyword: str | None = Query(None), - status: str | None = Query(None), - admin: User = Depends(get_admin_user), - db: AsyncSession = Depends(get_db), -): - items, total = await list_projects(db, user_id=user_id, page=page, page_size=page_size, keyword=keyword, status=status) - await log_operation( - db, - admin.id, - admin.username, - "查看真人素材项目列表", - "GET", - "/admin/private-portrait/projects", - detail=_json_detail( - { - "filters": {"user_id": user_id, "keyword": keyword, "status": status, "page": page, "page_size": page_size}, - "total": total, - "returned_count": len(items), - } - ), - ) - return PrivatePortraitProjectListOut(items=[project_to_out(item, include_user=True) for item in items], total=total, page=page, page_size=page_size) +@router.get("/stats", response_model=PrivatePortraitAdminStatsOut, summary="管理后台:私域人像素材统计") +async def admin_get_private_portrait_stats_api(user_id: str | None = Query(None), library_type: str | None = Query(None), admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)): + stats = await admin_get_private_portrait_stats(db, user_id=user_id, library_type=library_type) + await log_operation(db, admin.id, admin.username, "查看私域人像素材统计", "GET", "/admin/private-portrait/stats", detail=_json_detail({"filters": {"user_id": user_id, "library_type": library_type}, "stats": stats.model_dump(mode="json")})) + return stats -@router.get("/assets", response_model=PrivatePortraitAssetListOut) -async def admin_list_private_portrait_assets( - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - user_id: str | None = Query(None), - project_id: str | None = Query(None), - keyword: str | None = Query(None), - status: str | None = Query(None), - admin: User = Depends(get_admin_user), - db: AsyncSession = Depends(get_db), -): - assets, total, project_name_map = await list_assets(db, user_id=user_id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size) - await log_operation( - db, - admin.id, - admin.username, - "查看真人素材列表", - "GET", - "/admin/private-portrait/assets", - detail=_json_detail( - { - "filters": {"user_id": user_id, "project_id": project_id, "keyword": keyword, "status": status, "page": page, "page_size": page_size}, - "total": total, - "returned_count": len(assets), - } - ), - ) - return PrivatePortraitAssetListOut(items=[asset_to_out(asset, project_name=project_name_map.get(asset.project_id), include_user=True) for asset in assets], total=total, page=page, page_size=page_size) +@router.get("/projects", response_model=PrivatePortraitProjectListOut, summary="管理后台:查询私域人像项目列表") +async def admin_list_private_portrait_projects(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), user_id: str | None = Query(None), library_type: str | None = Query(None), keyword: str | None = Query(None), status: str | None = Query(None), admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)): + items, total = await admin_list_projects(db, user_id=user_id, library_type=library_type, page=page, page_size=page_size, keyword=keyword, status=status) + await log_operation(db, admin.id, admin.username, "查看私域人像素材项目列表", "GET", "/admin/private-portrait/projects", detail=_json_detail({"filters": {"user_id": user_id, "library_type": library_type, "keyword": keyword, "status": status, "page": page, "page_size": page_size}, "total": total, "returned_count": len(items)})) + await db.commit() + return PrivatePortraitProjectListOut(items=items, total=total, page=page, page_size=page_size) + + +@router.get("/assets", response_model=PrivatePortraitAssetListOut, summary="管理后台:查询私域人像素材列表") +async def admin_list_private_portrait_assets(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), user_id: str | None = Query(None), project_id: str | None = Query(None), library_type: str | None = Query(None), asset_type: str | None = Query(None), keyword: str | None = Query(None), status: str | None = Query(None), admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)): + items, total = await admin_list_assets(db, user_id=user_id, project_id=project_id, library_type=library_type, asset_type=asset_type, status=status, keyword=keyword, page=page, page_size=page_size) + await log_operation(db, admin.id, admin.username, "查看私域人像素材列表", "GET", "/admin/private-portrait/assets", detail=_json_detail({"filters": {"user_id": user_id, "project_id": project_id, "library_type": library_type, "asset_type": asset_type, "keyword": keyword, "status": status, "page": page, "page_size": page_size}, "total": total, "returned_count": len(items)})) + await db.commit() + return PrivatePortraitAssetListOut(items=items, total=total, page=page, page_size=page_size) diff --git a/video-gen-api/app/api/v1/__init__.py b/video-gen-api/app/api/v1/__init__.py index 77f2119a..94cbc99d 100644 --- a/video-gen-api/app/api/v1/__init__.py +++ b/video-gen-api/app/api/v1/__init__.py @@ -33,6 +33,7 @@ from app.api.v1.home_materials import router as home_materials_router 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 api_router = APIRouter() api_router.include_router(auth_router) diff --git a/video-gen-api/app/api/v1/admin.py b/video-gen-api/app/api/v1/admin.py index 5d2e8a8f..beac3cd3 100644 --- a/video-gen-api/app/api/v1/admin.py +++ b/video-gen-api/app/api/v1/admin.py @@ -174,7 +174,7 @@ async def create_user( user_type=req.user_type, frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value, allowed_menus=req.allowed_menus, - private_portrait_image_limit=req.private_portrait_image_limit, + private_portrait_asset_limit=req.private_portrait_asset_limit, ) user.credits = round(user.credits, 2) db.add(user) @@ -192,7 +192,7 @@ async def create_user( "username": username, "user_type": req.user_type, "frontend_user_kind": user.frontend_user_kind, - "private_portrait_image_limit": user.private_portrait_image_limit, + "private_portrait_asset_limit": user.private_portrait_asset_limit, "credits": user.credits, "phone": user.phone, "email": user.email, diff --git a/video-gen-api/app/api/v1/private_portrait.py b/video-gen-api/app/api/v1/private_portrait.py index b3c24c74..994fecb8 100644 --- a/video-gen-api/app/api/v1/private_portrait.py +++ b/video-gen-api/app/api/v1/private_portrait.py @@ -9,9 +9,11 @@ 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, + PrivatePortraitLibraryType, PrivatePortraitProjectStatus, PrivatePortraitRemoteDeleteStatus, ) @@ -20,6 +22,7 @@ from app.models.user import User from app.schemas.private_portrait import ( PrivatePortraitAssetCreate, PrivatePortraitAssetListOut, + PrivatePortraitAssetOut, PrivatePortraitDeleteOut, PrivatePortraitConfigOut, PrivatePortraitProjectCreate, @@ -30,13 +33,13 @@ 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 from app.services.private_portrait.asset_service import ( DOMAIN, asset_to_out, - create_asset, - create_validate_session, get_user_private_portrait_config, get_validate_session, handle_validate_callback, @@ -47,100 +50,102 @@ from app.services.private_portrait.asset_service import ( validate_session_to_out, ) from app.services.private_portrait.project_service import ( - create_project, get_user_project, list_projects, project_to_out, refresh_project_counters, soft_delete_project, - update_project, +) +from app.services.private_portrait.real_person.service import ( + create_real_person_asset, + create_real_person_project, + create_real_person_validate_session, + update_real_person_project, ) -router = APIRouter(tags=["private-portrait"]) +router = APIRouter(tags=["私域真人素材库"]) 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) +@router.get( + "/private-portrait/config", + response_model=PrivatePortraitConfigOut, + summary="获取当前用户私域人像素材额度配置", + description="返回私域人像素材总量限制。额度由真人认证素材库与虚拟人像素材库共用,图片和视频共用,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) -@router.post("/private-portrait/projects", response_model=PrivatePortraitProjectCreateWithValidateOut) +@router.get( + "/private-portrait/meta/enums", + response_model=PrivatePortraitEnumMetaOut, + summary="获取私域人像素材库枚举说明", + description="给前端展示状态、类型、素材库类型使用。Audio 仅作为火山支持项展示,当前业务不开放上传。", +) +async def get_private_portrait_enum_meta(): + return build_private_portrait_enum_meta() + + +@router.post( + "/private-portrait/projects", + response_model=PrivatePortraitProjectCreateWithValidateOut, + summary="创建真人认证素材项目并生成认证会话", + description="创建本地真人素材项目,随后调用火山 CreateVisualValidateSession 返回 H5Link。用户完成认证后,回调会创建本地 Asset Group 映射。", +) async def create_private_portrait_project(payload: PrivatePortraitProjectCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): - project = await create_project(db, user_id=current_user.id, payload=payload) - session = await create_validate_session( - db, - user_id=current_user.id, - project_id=project.id, - callback_redirect_url=payload.callback_redirect_url, - ) - out = PrivatePortraitProjectCreateWithValidateOut( - project=project_to_out(project), - validate_session=validate_session_to_out(session), - poll_interval_ms=2000, - ) + project = await create_real_person_project(db, user_id=current_user.id, payload=payload) + 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 = PrivatePortraitProjectCreateWithValidateOut(project=project_to_out(project), validate_session=validate_session_to_out(session), poll_interval_ms=2000) await db.commit() return out -@router.get("/private-portrait/projects", response_model=PrivatePortraitProjectListOut) +@router.get( + "/private-portrait/projects", + response_model=PrivatePortraitProjectListOut, + summary="查询当前用户真人认证素材项目列表", + description="只返回 library_type=real_person 的项目。默认查询 active 项目,可用 status 覆盖。", +) async def list_private_portrait_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), + 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) + 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.REAL_PERSON.value) await refresh_project_counters(db, [item.id for item in items]) await db.commit() - items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status) + 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.REAL_PERSON.value) 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) +@router.get("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="获取真人认证素材项目详情") 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)) + 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) +@router.put("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="更新真人认证素材项目") 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_project(db, user_id=current_user.id, project_id=project_id, payload=payload) + project = await update_real_person_project(db, user_id=current_user.id, project_id=project_id, payload=payload) out = project_to_out(project) await db.commit() return out -@router.delete("/private-portrait/projects/{project_id}", response_model=PrivatePortraitDeleteOut) +@router.delete("/private-portrait/projects/{project_id}", response_model=PrivatePortraitDeleteOut, summary="删除真人认证素材项目") 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) + 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 await db.commit() try: @@ -153,30 +158,26 @@ 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) +@router.post("/private-portrait/projects/{project_id}/validate-sessions", response_model=PrivatePortraitValidateSessionOut, summary="重新创建真人认证会话") 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_validate_session(db, user_id=current_user.id, project_id=project_id, callback_redirect_url=payload.callback_redirect_url) + 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) await db.commit() return out -@router.get("/private-portrait/validate-sessions/{session_id}", response_model=PrivatePortraitValidateSessionOut) +@router.get("/private-portrait/validate-sessions/{session_id}", response_model=PrivatePortraitValidateSessionOut, summary="查询真人认证会话状态") 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") +@router.get("/private-portrait/validate-callback", summary="火山真人认证回调入口") 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) params.pop("redirect_url", None) session = await handle_validate_callback(db, session_id=session_id, query_params=params) - redirect_params = { - "session_id": session.id, - "status": session.status, - "resultCode": session.result_code or "", - } + redirect_params = {"session_id": session.id, "status": session.status, "resultCode": session.result_code or ""} if session.remote_group_id: redirect_params["remote_group_id"] = session.remote_group_id response = {"session_id": session.id, "status": session.status, "resultCode": session.result_code, "remote_group_id": session.remote_group_id} @@ -188,9 +189,9 @@ async def private_portrait_validate_callback(session_id: str, request: Request, return response -@router.post("/private-portrait/projects/{project_id}/assets") +@router.post("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetOut, summary="上传真人认证素材", description="当前支持 Image / Video。Audio 暂不开放。CreateAsset 是异步接口,返回后需要轮询到 Active 才可用于生成。") 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_asset(db, user_id=current_user.id, project_id=project_id, payload=payload) + asset = await create_real_person_asset(db, user_id=current_user.id, project_id=project_id, payload=payload) asset_id_snapshot = asset.id project_id_snapshot = asset.project_id out = asset_to_out(asset) @@ -205,32 +206,34 @@ async def create_private_portrait_asset(project_id: str, payload: PrivatePortrai return out -@router.get("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut) -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), 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) +@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)): + 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}") +@router.get("/private-portrait/assets/{asset_id}", summary="获取真人认证素材详情") 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).limit(1))).scalar_one_or_none() + 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: - raise HTTPException(status_code=404, detail="真人素材不存在") + raise HTTPException(status_code=404, detail="私域人像素材不存在") project = (await db.execute(select(PrivatePortraitProject).where(PrivatePortraitProject.id == asset.project_id).limit(1))).scalar_one_or_none() return asset_to_out(asset, project_name=project.name if project else None) -@router.post("/private-portrait/assets/{asset_id}/sync") +@router.post("/private-portrait/assets/{asset_id}/sync", summary="同步真人认证素材状态") 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: + raise HTTPException(status_code=404, detail="私域人像素材不存在") out = asset_to_out(asset) await db.commit() return out -@router.delete("/private-portrait/assets/{asset_id}", response_model=PrivatePortraitDeleteOut) +@router.delete("/private-portrait/assets/{asset_id}", response_model=PrivatePortraitDeleteOut, summary="删除真人认证素材") 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) + 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 project_id_snapshot = asset.project_id await db.commit() @@ -244,7 +247,7 @@ 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) -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), 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) +@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)): + 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) diff --git a/video-gen-api/app/api/v1/private_portrait_virtual.py b/video-gen-api/app/api/v1/private_portrait_virtual.py new file mode 100644 index 00000000..a50c725c --- /dev/null +++ b/video-gen-api/app/api/v1/private_portrait_virtual.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_current_user, get_db +from app.enums.private_portrait import ( + PrivatePortraitEventSource, + PrivatePortraitEventStatus, + PrivatePortraitEventType, + PrivatePortraitLibraryType, + PrivatePortraitProjectStatus, + PrivatePortraitRemoteDeleteStatus, +) +from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject +from app.models.user import User +from app.schemas.private_portrait import ( + PrivatePortraitAssetCreate, + PrivatePortraitAssetListOut, + PrivatePortraitAssetOut, + PrivatePortraitConfigOut, + PrivatePortraitDeleteOut, + PrivatePortraitEnumMetaOut, + PrivatePortraitProjectListOut, + PrivatePortraitProjectOut, + PrivatePortraitProjectUpdate, + PrivatePortraitSelectableAssetListOut, + PrivatePortraitVirtualProjectCreate, + build_private_portrait_enum_meta, +) +from app.services.operation_log_service import log_operation_error, log_operation_event +from app.services.private_portrait.asset_service import ( + DOMAIN, + asset_to_out, + get_user_private_portrait_config, + list_assets, + list_selectable_assets, + soft_delete_asset, + sync_asset_status, +) +from app.services.private_portrait.project_service import ( + get_user_project, + list_projects, + project_to_out, + refresh_project_counters, + soft_delete_project, +) +from app.services.private_portrait.virtual.service import create_virtual_asset, create_virtual_project, update_virtual_project + +router = APIRouter(tags=["私域虚拟人像素材库"]) + + +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}) + + +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}) + + +@router.get("/private-portrait/virtual/config", response_model=PrivatePortraitConfigOut, summary="获取虚拟人像素材库额度配置", description="额度与真人认证素材库共用;图片/视频共用;Audio 暂不开放。") +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="获取虚拟人像素材库枚举说明") +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。") +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) + await db.commit() + 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)): + 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]) + await db.commit() + 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) + 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="获取虚拟人像项目详情") +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="更新虚拟人像项目") +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) + await db.commit() + return out + + +@router.delete("/private-portrait/virtual-projects/{project_id}", response_model=PrivatePortraitDeleteOut, summary="删除虚拟人像项目") +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 + await db.commit() + try: + from app.tasks.private_portrait_asset_tasks import delete_private_portrait_project_remote + + delete_private_portrait_project_remote.delay(project_id_snapshot) + _log_task_dispatch_success(task_name="private_portrait.delete_project_remote", user_id=current_user.id, project_id=project_id_snapshot) + except Exception as exc: + _log_task_dispatch_failed(task_name="private_portrait.delete_project_remote", user_id=current_user.id, project_id=project_id_snapshot, exc=exc) + 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 才可用于生成。") +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 + project_id_snapshot = asset.project_id + out = asset_to_out(asset) + await db.commit() + try: + from app.tasks.private_portrait_asset_tasks import poll_private_portrait_asset_status + + poll_private_portrait_asset_status.delay(asset_id_snapshot) + _log_task_dispatch_success(task_name="private_portrait.poll_asset_status", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot) + except Exception as exc: + _log_task_dispatch_failed(task_name="private_portrait.poll_asset_status", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot, exc=exc) + 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)): + 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="获取虚拟人像素材详情") +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: + raise HTTPException(status_code=404, detail="虚拟人像素材不存在") + project = (await db.execute(select(PrivatePortraitProject).where(PrivatePortraitProject.id == asset.project_id).limit(1))).scalar_one_or_none() + 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="同步虚拟人像素材状态") +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: + raise HTTPException(status_code=404, detail="虚拟人像素材不存在") + out = asset_to_out(asset) + await db.commit() + return out + + +@router.delete("/private-portrait/virtual-assets/{asset_id}", response_model=PrivatePortraitDeleteOut, summary="删除虚拟人像素材") +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 + project_id_snapshot = asset.project_id + await db.commit() + try: + from app.tasks.private_portrait_asset_tasks import delete_private_portrait_asset_remote + + delete_private_portrait_asset_remote.delay(asset_id_snapshot) + _log_task_dispatch_success(task_name="private_portrait.delete_asset_remote", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot) + except Exception as exc: + _log_task_dispatch_failed(task_name="private_portrait.delete_asset_remote", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot, exc=exc) + 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)): + 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) diff --git a/video-gen-api/app/enums/private_portrait.py b/video-gen-api/app/enums/private_portrait.py index c9d7cf4a..87239082 100644 --- a/video-gen-api/app/enums/private_portrait.py +++ b/video-gen-api/app/enums/private_portrait.py @@ -2,14 +2,18 @@ from __future__ import annotations from enum import Enum -# 用户真人素材图片默认上限。users.private_portrait_image_limit = 0 表示关闭模块;>0 表示启用并限制总量。 -PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT = 5 +# 用户私域人像素材默认上限。users.private_portrait_asset_limit = 0 表示关闭模块;>0 表示启用并限制总素材量。 +# 统计口径:真人 + 虚拟;图片 + 视频。音频当前业务暂不开放。 +PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT = 5 -# 火山 Ark 私域真人素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。 +# 火山 Ark 私域素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。 # 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。 PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME = "default" -PRIVATE_PORTRAIT_GROUP_TYPE = "LivenessFace" +PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE = "LivenessFace" +PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE = "AIGC" +# 兼容旧代码导入,默认代表真人认证素材组类型。 +PRIVATE_PORTRAIT_GROUP_TYPE = PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE PRIVATE_PORTRAIT_VERIFY_TYPE = "real_time" PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE = "10000" PRIVATE_PORTRAIT_ASSET_URI_PREFIX = "asset://" @@ -20,7 +24,9 @@ ARK_PRIVATE_PORTRAIT_REGION = "cn-beijing" ARK_PRIVATE_PORTRAIT_HOST = "ark.cn-beijing.volcengineapi.com" PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS = 20 +PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS = 30 PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT = 60 +PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT = 120 PRIVATE_PORTRAIT_ASSET_POLL_BATCH_SIZE = 50 PRIVATE_PORTRAIT_REMOTE_DELETE_RECOVERY_BATCH_SIZE = 50 PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES = 30 @@ -29,6 +35,7 @@ PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES = 30 PRIVATE_PORTRAIT_ACTION_QPS_LIMITS: dict[str, int] = { "CreateVisualValidateSession": 3, "GetVisualValidateResult": 3, + "CreateAssetGroup": 10, "CreateAsset": 1, "ListAssetGroups": 10, "ListAssets": 10, @@ -44,6 +51,7 @@ PRIVATE_PORTRAIT_ACTION_QPS_LIMITS: dict[str, int] = { class ArkPrivatePortraitAction(str, Enum): CREATE_VISUAL_VALIDATE_SESSION = "CreateVisualValidateSession" GET_VISUAL_VALIDATE_RESULT = "GetVisualValidateResult" + CREATE_ASSET_GROUP = "CreateAssetGroup" CREATE_ASSET = "CreateAsset" GET_ASSET = "GetAsset" LIST_ASSETS = "ListAssets" @@ -55,10 +63,17 @@ class ArkPrivatePortraitAction(str, Enum): DELETE_ASSET_GROUP = "DeleteAssetGroup" +class PrivatePortraitLibraryType(str, Enum): + REAL_PERSON = "real_person" + AIGC_VIRTUAL = "aigc_virtual" + + class PrivatePortraitProjectStatus(str, Enum): VALIDATING = "validating" ACTIVE = "active" VALIDATE_FAILED = "validate_failed" + CREATING_REMOTE_GROUP = "creating_remote_group" + CREATE_GROUP_FAILED = "create_group_failed" DELETED = "deleted" @@ -72,6 +87,7 @@ class PrivatePortraitValidateSessionStatus(str, Enum): class PrivatePortraitAssetGroupStatus(str, Enum): + CREATING = "creating" ACTIVE = "active" LOCAL_DELETED = "local_deleted" REMOTE_DELETED = "remote_deleted" @@ -92,7 +108,13 @@ class PrivatePortraitAssetStatus(str, Enum): class PrivatePortraitAssetType(str, Enum): IMAGE = "Image" VIDEO = "Video" - AUDIO = "Audio" + AUDIO = "Audio" # 火山支持,但当前业务暂不开放。 + + +PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES = { + PrivatePortraitAssetType.IMAGE.value, + PrivatePortraitAssetType.VIDEO.value, +} class PrivatePortraitRemoteDeleteStatus(str, Enum): @@ -125,9 +147,19 @@ class PrivatePortraitEventSource(str, Enum): class PrivatePortraitEventType(str, Enum): + QUOTA_CHECK_START = "QUOTA_CHECK_START" + QUOTA_CHECK_PASS = "QUOTA_CHECK_PASS" + QUOTA_CHECK_DENY = "QUOTA_CHECK_DENY" + PROJECT_CREATE = "PROJECT_CREATE" PROJECT_UPDATE = "PROJECT_UPDATE" PROJECT_DELETE = "PROJECT_DELETE" + + VIRTUAL_PROJECT_CREATE_START = "VIRTUAL_PROJECT_CREATE_START" + VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START" + VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS" + VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED" + VALIDATE_SESSION_CREATE = "VALIDATE_SESSION_CREATE" VALIDATE_SESSION_CREATE_FAILED = "VALIDATE_SESSION_CREATE_FAILED" VALIDATE_CALLBACK_RECEIVED = "VALIDATE_CALLBACK_RECEIVED" @@ -163,7 +195,6 @@ class PrivatePortraitEventType(str, Enum): PROJECT_DELETE_REMOTE_SUCCESS = "PROJECT_DELETE_REMOTE_SUCCESS" PROJECT_DELETE_REMOTE_FAILED = "PROJECT_DELETE_REMOTE_FAILED" - TASK_DISPATCH_SUCCESS = "TASK_DISPATCH_SUCCESS" TASK_DISPATCH_FAILED = "TASK_DISPATCH_FAILED" diff --git a/video-gen-api/app/models/private_portrait/asset.py b/video-gen-api/app/models/private_portrait/asset.py index f4395fe7..cd35f40e 100644 --- a/video-gen-api/app/models/private_portrait/asset.py +++ b/video-gen-api/app/models/private_portrait/asset.py @@ -2,12 +2,13 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text +from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, text from sqlalchemy.orm import Mapped, mapped_column from app.enums.private_portrait import ( PrivatePortraitAssetStatus, PrivatePortraitAssetType, + PrivatePortraitLibraryType, PrivatePortraitRemoteDeleteStatus, ) from app.models.base import Base, SoftDeleteMixin, TimestampMixin @@ -19,10 +20,13 @@ class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin): __tablename__ = "private_portrait_assets" __table_args__ = ( Index("uq_private_portrait_assets_remote_asset_id", "remote_asset_id", unique=True), + Index("idx_private_portrait_assets_user_library_status_created", "user_id", "library_type", "status", "created_at"), Index("idx_private_portrait_assets_user_status_created", "user_id", "status", "created_at"), Index("idx_private_portrait_assets_project_status_created", "project_id", "status", "created_at"), Index("idx_private_portrait_assets_group_status_created", "group_id", "status", "created_at"), Index("idx_private_portrait_assets_remote_project_name", "remote_project_name"), + Index("idx_private_portrait_assets_library_type", "library_type"), + Index("idx_private_portrait_assets_asset_type", "asset_type"), Index( "idx_private_portrait_assets_next_poll_status", "next_poll_at", @@ -36,6 +40,13 @@ class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin): user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) project_id: Mapped[str] = mapped_column(String(32), ForeignKey("private_portrait_projects.id", ondelete="CASCADE"), nullable=False, index=True) group_id: Mapped[str] = mapped_column(String(32), ForeignKey("private_portrait_asset_groups.id", ondelete="CASCADE"), nullable=False, index=True) + library_type: Mapped[str] = mapped_column( + String(32), + nullable=False, + default=PrivatePortraitLibraryType.REAL_PERSON.value, + server_default=PrivatePortraitLibraryType.REAL_PERSON.value, + index=True, + ) remote_group_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) remote_asset_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True) @@ -45,6 +56,10 @@ class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin): preview_url: Mapped[str | None] = mapped_column(Text, nullable=True) remote_url: Mapped[str | None] = mapped_column(Text, nullable=True) remote_url_expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + video_duration: Mapped[float | None] = mapped_column(Float, nullable=True, comment="视频素材时长,秒") + video_cover_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="视频素材封面预览地址") + file_size: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="素材文件大小,字节") + mime_type: Mapped[str | None] = mapped_column(String(128), nullable=True) status: Mapped[str] = mapped_column( String(32), nullable=False, diff --git a/video-gen-api/app/models/private_portrait/asset_group.py b/video-gen-api/app/models/private_portrait/asset_group.py index 0130136a..dcec31da 100644 --- a/video-gen-api/app/models/private_portrait/asset_group.py +++ b/video-gen-api/app/models/private_portrait/asset_group.py @@ -6,8 +6,9 @@ from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text from sqlalchemy.orm import Mapped, mapped_column from app.enums.private_portrait import ( - PRIVATE_PORTRAIT_GROUP_TYPE, + PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE, PrivatePortraitAssetGroupStatus, + PrivatePortraitLibraryType, PrivatePortraitRemoteDeleteStatus, ) from app.models.base import Base, SoftDeleteMixin, TimestampMixin @@ -20,9 +21,11 @@ class PrivatePortraitAssetGroup(Base, TimestampMixin, SoftDeleteMixin): __table_args__ = ( Index("uq_private_portrait_asset_groups_remote_group_id", "remote_group_id", unique=True), Index("idx_private_portrait_asset_groups_user_project", "user_id", "project_id"), + Index("idx_private_portrait_asset_groups_user_library", "user_id", "library_type"), Index("idx_private_portrait_asset_groups_project_status", "project_id", "status"), Index("idx_private_portrait_asset_groups_remote_delete_status", "remote_delete_status"), Index("idx_private_portrait_asset_groups_remote_project_name", "remote_project_name"), + Index("idx_private_portrait_asset_groups_library_type", "library_type"), Index( "uq_private_portrait_asset_groups_one_active_project", "project_id", @@ -34,10 +37,17 @@ class PrivatePortraitAssetGroup(Base, TimestampMixin, SoftDeleteMixin): id: Mapped[str] = mapped_column(String(32), primary_key=True) user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) project_id: Mapped[str] = mapped_column(String(32), ForeignKey("private_portrait_projects.id", ondelete="CASCADE"), nullable=False, index=True) + library_type: Mapped[str] = mapped_column( + String(32), + nullable=False, + default=PrivatePortraitLibraryType.REAL_PERSON.value, + server_default=PrivatePortraitLibraryType.REAL_PERSON.value, + index=True, + ) remote_group_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) remote_group_name: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True) - group_type: Mapped[str] = mapped_column(String(32), nullable=False, default=PRIVATE_PORTRAIT_GROUP_TYPE, server_default=PRIVATE_PORTRAIT_GROUP_TYPE) + group_type: Mapped[str] = mapped_column(String(32), nullable=False, default=PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE, server_default=PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE) status: Mapped[str] = mapped_column( String(32), nullable=False, diff --git a/video-gen-api/app/models/private_portrait/project.py b/video-gen-api/app/models/private_portrait/project.py index ab919281..2ee3a013 100644 --- a/video-gen-api/app/models/private_portrait/project.py +++ b/video-gen-api/app/models/private_portrait/project.py @@ -5,15 +5,16 @@ from datetime import datetime from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text from sqlalchemy.orm import Mapped, mapped_column -from app.enums.private_portrait import PrivatePortraitProjectStatus +from app.enums.private_portrait import PrivatePortraitLibraryType, PrivatePortraitProjectStatus from app.models.base import Base, SoftDeleteMixin, TimestampMixin class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin): - """用户本地真人素材项目组。remote_project_name 是火山 ProjectName 快照。""" + """用户本地私域人像素材项目组。remote_project_name 是火山 ProjectName 快照。""" __tablename__ = "private_portrait_projects" __table_args__ = ( + Index("idx_private_portrait_projects_user_library_status_created", "user_id", "library_type", "status", "created_at"), Index("idx_private_portrait_projects_user_status_created", "user_id", "status", "created_at"), Index( "idx_private_portrait_projects_user_deleted", @@ -22,10 +23,19 @@ class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin): postgresql_where=text("deleted_at IS NULL"), ), Index("idx_private_portrait_projects_remote_project_name", "remote_project_name"), + Index("idx_private_portrait_projects_library_type", "library_type"), ) id: Mapped[str] = mapped_column(String(32), primary_key=True) user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + library_type: Mapped[str] = mapped_column( + String(32), + nullable=False, + default=PrivatePortraitLibraryType.REAL_PERSON.value, + server_default=PrivatePortraitLibraryType.REAL_PERSON.value, + index=True, + comment="素材库类型:real_person 真人认证;aigc_virtual 虚拟人像", + ) name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="用户展示项目名") name_slug: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="项目名安全 slug") remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True, comment="火山 ProjectName 快照") @@ -39,5 +49,9 @@ class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin): ) asset_group_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + image_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + video_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") active_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + active_image_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + active_video_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/video-gen-api/app/models/user.py b/video-gen-api/app/models/user.py index 40f2110f..06ef1d87 100644 --- a/video-gen-api/app/models/user.py +++ b/video-gen-api/app/models/user.py @@ -39,8 +39,8 @@ class User(Base, TimestampMixin): ) allowed_menus: Mapped[list | None] = mapped_column(JSON, nullable=True) - # 真人素材库图片总量限制。0 表示关闭真人素材模块;>0 表示启用并限制用户所有真人素材图片总量。 - private_portrait_image_limit: Mapped[int] = mapped_column( + # 私域人像素材总量限制。0 表示关闭模块;>0 表示启用并限制真人/虚拟、图片/视频素材总量。 + private_portrait_asset_limit: Mapped[int] = mapped_column( Integer, default=5, server_default="5", nullable=False ) diff --git a/video-gen-api/app/schemas/admin.py b/video-gen-api/app/schemas/admin.py index 230135d4..fc4b0854 100644 --- a/video-gen-api/app/schemas/admin.py +++ b/video-gen-api/app/schemas/admin.py @@ -58,7 +58,7 @@ class AdminUserOut(BaseModel): last_login_at: NaiveDatetimeOptional = None allowed_menus: list | None = None resource_capacity: ResourceCapacityUsageOut | None = None - private_portrait_image_limit: int = 5 + private_portrait_asset_limit: int = 5 model_config = {"from_attributes": True} @@ -72,7 +72,7 @@ class CreateUserRequest(BaseModel): user_type: str = Field(default="frontend", pattern="^(frontend|admin)$") frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$") allowed_menus: list | None = None - private_portrait_image_limit: int = Field(default=5, ge=0, le=9999) + private_portrait_asset_limit: int = Field(default=5, ge=0, le=9999, description="私域人像素材总量限制,真人/虚拟、图片/视频共用,0 表示关闭") class UpdateFrontendUserKindRequest(BaseModel): diff --git a/video-gen-api/app/schemas/private_portrait.py b/video-gen-api/app/schemas/private_portrait.py index 8135b72a..c74b193e 100644 --- a/video-gen-api/app/schemas/private_portrait.py +++ b/video-gen-api/app/schemas/private_portrait.py @@ -4,36 +4,67 @@ from typing import Any from pydantic import BaseModel, Field, field_validator -from app.enums.private_portrait import PrivatePortraitAssetType +from app.enums.private_portrait import ( + PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES, + PrivatePortraitAssetStatus, + PrivatePortraitAssetType, + PrivatePortraitLibraryType, + PrivatePortraitProjectStatus, +) from app.schemas.common import NaiveDatetimeOptional +class PrivatePortraitEnumItem(BaseModel): + value: str + label: str + description: str | None = None + + +class PrivatePortraitEnumMetaOut(BaseModel): + library_types: list[PrivatePortraitEnumItem] + asset_types: list[PrivatePortraitEnumItem] + project_statuses: list[PrivatePortraitEnumItem] + asset_statuses: list[PrivatePortraitEnumItem] + + class PrivatePortraitConfigOut(BaseModel): - enabled: bool - image_limit: int - used_image_count: int - remaining_image_count: int + 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。") class PrivatePortraitAdminConfigUpdate(BaseModel): - private_portrait_image_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: str | None = Field(None, max_length=2000) - callback_redirect_url: str | None = Field(None, description="项目创建时真人认证完成后的手机端提示页地址") + 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="仅真人认证使用:认证完成后的手机端提示页地址。") + + +class PrivatePortraitVirtualProjectCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=128, description="虚拟人像素材项目组名称。创建后会同步创建火山 Asset Group。") + 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: str | None = Field(None, max_length=2000) - status: str | None = None + 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="项目状态。普通前端不建议手动变更,仅管理/排查使用。") 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 @@ -41,7 +72,11 @@ class PrivatePortraitProjectOut(BaseModel): 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 @@ -81,18 +116,17 @@ class PrivatePortraitValidateSessionOut(BaseModel): model_config = {"from_attributes": True} - - class PrivatePortraitProjectCreateWithValidateOut(BaseModel): project: PrivatePortraitProjectOut validate_session: PrivatePortraitValidateSessionOut - poll_interval_ms: int = Field(default=2000, description="PC 端轮询认证状态的建议间隔,单位毫秒") + 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 @@ -108,16 +142,22 @@ class PrivatePortraitAssetGroupOut(BaseModel): class PrivatePortraitAssetCreate(BaseModel): - url: str = Field(..., min_length=1, description="已上传到本系统且可公网访问的素材 URL") - asset_type: str = Field(default=PrivatePortraitAssetType.IMAGE.value) - name: str | None = Field(None, max_length=256) + 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 类型。") @field_validator("asset_type") @classmethod def validate_asset_type(cls, v: str) -> str: value = v or PrivatePortraitAssetType.IMAGE.value if value not in {item.value for item in PrivatePortraitAssetType}: - raise ValueError("asset_type 仅支持 Image/Video/Audio") + raise ValueError("asset_type 仅支持 Image/Video,Audio 暂未开放") + if value not in PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES: + raise ValueError("Audio 暂未开放,当前仅支持 Image/Video") return value @@ -127,6 +167,7 @@ class PrivatePortraitAssetOut(BaseModel): 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 @@ -134,10 +175,16 @@ class PrivatePortraitAssetOut(BaseModel): 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: dict[str, Any] | None = None + moderation: Any = None last_poll_at: NaiveDatetimeOptional = None next_poll_at: NaiveDatetimeOptional = None poll_count: int = 0 @@ -162,10 +209,15 @@ 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 - status: str + 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 @@ -178,4 +230,32 @@ class PrivatePortraitSelectableAssetListOut(BaseModel): class PrivatePortraitDeleteOut(BaseModel): success: bool = True - remote_delete_status: str | None = None + remote_delete_status: str + + +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 + + +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], + ) diff --git a/video-gen-api/app/schemas/user.py b/video-gen-api/app/schemas/user.py index 63b8d57a..ada58f2a 100644 --- a/video-gen-api/app/schemas/user.py +++ b/video-gen-api/app/schemas/user.py @@ -15,7 +15,7 @@ class UserOut(BaseModel): allowed_menus: list | None = None must_set_password: bool = False resource_capacity: ResourceCapacityUsageOut | None = None - private_portrait_image_limit: int = 5 + private_portrait_asset_limit: int = 5 team_id: str | None = None team_name: str | None = None is_team_manager: bool = False diff --git a/video-gen-api/app/services/private_portrait/admin/__init__.py b/video-gen-api/app/services/private_portrait/admin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/video-gen-api/app/services/private_portrait/admin/asset_query_service.py b/video-gen-api/app/services/private_portrait/admin/asset_query_service.py new file mode 100644 index 00000000..57a47228 --- /dev/null +++ b/video-gen-api/app/services/private_portrait/admin/asset_query_service.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.private_portrait.asset_service import asset_to_out, list_assets + + +async def admin_list_assets( + db: AsyncSession, + *, + user_id: str | None = None, + project_id: str | None = None, + library_type: str | None = None, + asset_type: str | None = None, + keyword: str | None = None, + status: str | None = None, + page: int = 1, + page_size: int = 20, +): + assets, total, project_name_map = await list_assets(db, user_id=user_id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size, library_type=library_type, asset_type=asset_type) + return [asset_to_out(asset, project_name=project_name_map.get(asset.project_id), include_user=True) for asset in assets], total diff --git a/video-gen-api/app/services/private_portrait/admin/project_query_service.py b/video-gen-api/app/services/private_portrait/admin/project_query_service.py new file mode 100644 index 00000000..cfcd0b7d --- /dev/null +++ b/video-gen-api/app/services/private_portrait/admin/project_query_service.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.private_portrait.project_service import list_projects, project_to_out, refresh_project_counters + + +async def admin_list_projects( + db: AsyncSession, + *, + user_id: str | None = None, + library_type: str | None = None, + keyword: str | None = None, + status: str | None = None, + page: int = 1, + page_size: int = 20, +): + items, total = await list_projects(db, user_id=user_id, page=page, page_size=page_size, keyword=keyword, status=status, library_type=library_type) + await refresh_project_counters(db, [item.id for item in items]) + return [project_to_out(item, include_user=True) for item in items], total diff --git a/video-gen-api/app/services/private_portrait/admin/stats_query_service.py b/video-gen-api/app/services/private_portrait/admin/stats_query_service.py new file mode 100644 index 00000000..242fd4cb --- /dev/null +++ b/video-gen-api/app/services/private_portrait/admin/stats_query_service.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.private_portrait import PrivatePortraitAssetStatus, PrivatePortraitAssetType, PrivatePortraitLibraryType +from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject +from app.schemas.private_portrait import PrivatePortraitAdminStatsOut + + +async def admin_get_private_portrait_stats(db: AsyncSession, *, user_id: str | None = None, library_type: str | None = None) -> PrivatePortraitAdminStatsOut: + project_filters = [PrivatePortraitProject.deleted_at.is_(None)] + asset_filters = [PrivatePortraitAsset.deleted_at.is_(None)] + if user_id: + project_filters.append(PrivatePortraitProject.user_id == user_id) + asset_filters.append(PrivatePortraitAsset.user_id == user_id) + if library_type: + project_filters.append(PrivatePortraitProject.library_type == library_type) + asset_filters.append(PrivatePortraitAsset.library_type == library_type) + + total_projects = (await db.execute(select(func.count(PrivatePortraitProject.id)).where(*project_filters))).scalar_one() + rows = await db.execute( + select(PrivatePortraitAsset.library_type, PrivatePortraitAsset.asset_type, PrivatePortraitAsset.status, func.count(PrivatePortraitAsset.id)) + .where(*asset_filters) + .group_by(PrivatePortraitAsset.library_type, PrivatePortraitAsset.asset_type, PrivatePortraitAsset.status) + ) + stats = PrivatePortraitAdminStatsOut(total_projects=int(total_projects or 0)) + for lib, asset_type, status, count in rows.all(): + n = int(count or 0) + stats.total_assets += n + if asset_type == PrivatePortraitAssetType.IMAGE.value: + stats.image_assets += n + elif asset_type == PrivatePortraitAssetType.VIDEO.value: + stats.video_assets += n + if status == PrivatePortraitAssetStatus.ACTIVE.value: + stats.active_assets += n + elif status == PrivatePortraitAssetStatus.PROCESSING.value: + stats.processing_assets += n + elif status == PrivatePortraitAssetStatus.FAILED.value: + stats.failed_assets += n + if lib == PrivatePortraitLibraryType.REAL_PERSON.value: + stats.real_person_assets += n + elif lib == PrivatePortraitLibraryType.AIGC_VIRTUAL.value: + stats.virtual_assets += n + return stats diff --git a/video-gen-api/app/services/private_portrait/ark_client.py b/video-gen-api/app/services/private_portrait/ark_client.py index 59ae84ff..c4dc970c 100644 --- a/video-gen-api/app/services/private_portrait/ark_client.py +++ b/video-gen-api/app/services/private_portrait/ark_client.py @@ -51,7 +51,7 @@ def _remote_error_http_status(code: str) -> int: class ArkPrivateAssetClient: - """火山 Ark 私域真人人像素材 API Client。只做 AK/SK 鉴权调用与响应标准化。""" + """火山 Ark 私域可信素材 Asset API Client。只做 AK/SK 鉴权调用与响应标准化。""" def __init__(self, *, ak: str | None = None, sk: str | None = None, for_celery: bool = False): self.ak = ak or settings.VOLC_SMS_ACCESS_KEY_ID @@ -66,6 +66,12 @@ class ArkPrivateAssetClient: async def get_visual_validate_result(self, *, project_name: str, byted_token: str) -> dict[str, Any]: return await self._call(ArkPrivatePortraitAction.GET_VISUAL_VALIDATE_RESULT, {"BytedToken": byted_token, "ProjectName": project_name}) + async def create_asset_group(self, *, project_name: str, name: str, description: str | None = None, group_type: str = "AIGC") -> dict[str, Any]: + payload: dict[str, Any] = {"Name": name, "GroupType": group_type, "ProjectName": project_name} + if description: + payload["Description"] = description + return await self._call(ArkPrivatePortraitAction.CREATE_ASSET_GROUP, payload) + async def create_asset(self, *, project_name: str, group_id: str, url: str, asset_type: str, name: str | None = None) -> dict[str, Any]: payload: dict[str, Any] = {"GroupId": group_id, "URL": url, "AssetType": asset_type, "ProjectName": project_name} if name: diff --git a/video-gen-api/app/services/private_portrait/asset_service.py b/video-gen-api/app/services/private_portrait/asset_service.py index 1424a807..e5adf6a3 100644 --- a/video-gen-api/app/services/private_portrait/asset_service.py +++ b/video-gen-api/app/services/private_portrait/asset_service.py @@ -6,33 +6,42 @@ from typing import Any from urllib.parse import urlencode from fastapi import HTTPException -from sqlalchemy import and_, func, select, update +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.enums.private_portrait import ( PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS, PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT, - PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT, - PRIVATE_PORTRAIT_GROUP_TYPE, + PRIVATE_PORTRAIT_ASSET_URI_PREFIX, + PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES, + PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE, PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE, PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES, + PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS, + PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT, PrivatePortraitAssetGroupStatus, PrivatePortraitAssetStatus, PrivatePortraitAssetType, PrivatePortraitEventSource, PrivatePortraitEventStatus, PrivatePortraitEventType, + PrivatePortraitLibraryType, PrivatePortraitProjectStatus, PrivatePortraitRemoteDeleteStatus, PrivatePortraitValidateSessionStatus, ) from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession -from app.models.user import User -from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitConfigOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut +from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut from app.services.operation_log_service import log_operation_error, log_operation_event from app.services.private_portrait.ark_client import ArkPrivateAssetClient from app.services.private_portrait.project_service import get_user_project, refresh_project_counters +from app.services.private_portrait.quota_service import ( + count_user_counting_assets, + ensure_private_portrait_asset_quota_available, + get_user_private_portrait_config, + set_user_private_portrait_limit, +) from app.utils.id_gen import generate_id DOMAIN = "private_portrait" @@ -53,7 +62,6 @@ def _loads(data: str | None) -> Any: return None - def _exception_message(exc: Exception) -> str: if isinstance(exc, HTTPException): detail = exc.detail @@ -65,7 +73,7 @@ def _exception_message(exc: Exception) -> str: def _public_url(url: str) -> str: - if url.startswith(("http://", "https://")): + if url.startswith(("http://", "https://", PRIVATE_PORTRAIT_ASSET_URI_PREFIX)): return url return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}" @@ -83,36 +91,31 @@ def _remote_group_name(user_id: str, project_name: str) -> str: return f"{user_id}-{safe_name}"[:128] -async def get_user_private_portrait_config(db: AsyncSession, *, user_id: str) -> PrivatePortraitConfigOut: - user = (await db.execute(select(User).where(User.id == user_id).limit(1))).scalar_one_or_none() - if not user: - raise HTTPException(status_code=404, detail="用户不存在") - limit = int(getattr(user, "private_portrait_image_limit", PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT) or 0) - used = await count_user_counting_image_assets(db, user_id=user_id) - return PrivatePortraitConfigOut(enabled=limit > 0, image_limit=limit, used_image_count=used, remaining_image_count=max(0, limit - used) if limit > 0 else 0) +def _asset_display_url(asset: PrivatePortraitAsset) -> str | None: + return asset.preview_url or asset.remote_url or asset.source_url or None -async def set_user_private_portrait_limit(db: AsyncSession, *, user_id: str, limit: int) -> User: - user = (await db.execute(select(User).where(User.id == user_id).limit(1))).scalar_one_or_none() - if not user: - raise HTTPException(status_code=404, detail="用户不存在") - user.private_portrait_image_limit = max(0, int(limit)) - await db.flush() - return user +def _provider_url(asset: PrivatePortraitAsset) -> str | None: + return f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}" if asset.remote_asset_id else None -async def count_user_counting_image_assets(db: AsyncSession, *, user_id: str) -> int: - statuses = [PrivatePortraitAssetStatus.CREATING.value, PrivatePortraitAssetStatus.PROCESSING.value, PrivatePortraitAssetStatus.ACTIVE.value] - total = (await db.execute(select(func.count(PrivatePortraitAsset.id)).where(PrivatePortraitAsset.user_id == user_id, PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.IMAGE.value, PrivatePortraitAsset.deleted_at.is_(None), PrivatePortraitAsset.status.in_(statuses)))).scalar_one() - return int(total or 0) +def _poll_interval_seconds(asset_type: str) -> int: + if asset_type == PrivatePortraitAssetType.VIDEO.value: + return PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS + return PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS -async def _lock_user_for_upload(db: AsyncSession, *, user_id: str) -> User: - # 锁 users 行,避免并发绕过用户总量限制。SQLite 会忽略 FOR UPDATE,不影响本地开发。 - user = (await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))).scalar_one_or_none() - if not user: - raise HTTPException(status_code=404, detail="用户不存在") - return user +def _poll_max_count(asset_type: str) -> int: + if asset_type == PrivatePortraitAssetType.VIDEO.value: + return PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT + return PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT + + +def _assert_enabled_asset_type(asset_type: str) -> None: + if asset_type not in {item.value for item in PrivatePortraitAssetType}: + raise HTTPException(status_code=400, detail="asset_type 仅支持 Image/Video,Audio 暂未开放") + if asset_type not in PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES: + raise HTTPException(status_code=400, detail="Audio 暂未开放,当前仅支持 Image/Video") def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_user: bool = False) -> PrivatePortraitValidateSessionOut: @@ -137,12 +140,14 @@ def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_ def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None, include_user: bool = False) -> PrivatePortraitAssetOut: + display_url = _asset_display_url(asset) return PrivatePortraitAssetOut( id=asset.id, user_id=asset.user_id if include_user else None, project_id=asset.project_id, project_name=project_name, group_id=asset.group_id, + library_type=asset.library_type, remote_group_id=asset.remote_group_id, remote_asset_id=asset.remote_asset_id, remote_project_name=asset.remote_project_name, @@ -150,8 +155,14 @@ def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None name=asset.name, source_url=asset.source_url, preview_url=asset.preview_url, + display_url=display_url, + provider_url=_provider_url(asset), remote_url=asset.remote_url, remote_url_expired_at=asset.remote_url_expired_at, + video_duration=asset.video_duration, + video_cover_url=asset.video_cover_url, + file_size=asset.file_size, + mime_type=asset.mime_type, status=asset.status, moderation=_loads(asset.moderation_json), last_poll_at=asset.last_poll_at, @@ -166,15 +177,18 @@ def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None ) -async def _get_existing_active_group(db: AsyncSession, *, project_id: str) -> PrivatePortraitAssetGroup | None: +async def _get_existing_active_group(db: AsyncSession, *, project_id: str, library_type: str | None = None) -> PrivatePortraitAssetGroup | None: + filters = [ + PrivatePortraitAssetGroup.project_id == project_id, + PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value, + PrivatePortraitAssetGroup.deleted_at.is_(None), + ] + if library_type: + filters.append(PrivatePortraitAssetGroup.library_type == library_type) return ( await db.execute( select(PrivatePortraitAssetGroup) - .where( - PrivatePortraitAssetGroup.project_id == project_id, - PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value, - PrivatePortraitAssetGroup.deleted_at.is_(None), - ) + .where(*filters) .order_by(PrivatePortraitAssetGroup.created_at.desc()) .limit(1) ) @@ -182,7 +196,9 @@ async def _get_existing_active_group(db: AsyncSession, *, project_id: str) -> Pr async def _ensure_project_can_validate(db: AsyncSession, *, project: PrivatePortraitProject) -> PrivatePortraitValidateSession | None: - active_group = await _get_existing_active_group(db, project_id=project.id) + if project.library_type != PrivatePortraitLibraryType.REAL_PERSON.value: + raise HTTPException(status_code=400, detail="虚拟人像项目不支持真人认证") + active_group = await _get_existing_active_group(db, project_id=project.id, library_type=project.library_type) if active_group or project.status == PrivatePortraitProjectStatus.ACTIVE.value: raise HTTPException(status_code=409, detail="该真人素材项目已完成认证,不能重复认证") @@ -206,12 +222,7 @@ async def _ensure_project_can_validate(db: AsyncSession, *, project: PrivatePort select(PrivatePortraitValidateSession) .where( PrivatePortraitValidateSession.project_id == project.id, - PrivatePortraitValidateSession.status.in_( - [ - PrivatePortraitValidateSessionStatus.CREATED.value, - PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value, - ] - ), + PrivatePortraitValidateSession.status.in_([PrivatePortraitValidateSessionStatus.CREATED.value, PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value]), PrivatePortraitValidateSession.expired_at.is_not(None), PrivatePortraitValidateSession.expired_at > now, ) @@ -223,7 +234,7 @@ async def _ensure_project_can_validate(db: AsyncSession, *, project: PrivatePort async def create_validate_session(db: AsyncSession, *, user_id: str, project_id: str, callback_redirect_url: str | None = None) -> PrivatePortraitValidateSession: - project = await get_user_project(db, user_id=user_id, project_id=project_id) + project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value) reusable_session = await _ensure_project_can_validate(db, project=project) if reusable_session: return reusable_session @@ -246,10 +257,8 @@ async def create_validate_session(db: AsyncSession, *, user_id: str, project_id: session.h5_link = resp.get("H5Link") or resp.get("h5Link") session.raw_response_json = _json(resp) await db.flush() - # created_at / updated_at 来自数据库默认值或 onupdate,flush 后可能处于 expired 状态。 - # 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。 await db.refresh(session) - log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, detail={"remote_project_name": project.remote_project_name}) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, detail={"remote_project_name": project.remote_project_name, "library_type": project.library_type}) return session except Exception as exc: session.status = PrivatePortraitValidateSessionStatus.FAILED.value @@ -259,6 +268,7 @@ async def create_validate_session(db: AsyncSession, *, user_id: str, project_id: log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, exc=exc) raise + async def get_validate_session(db: AsyncSession, *, user_id: str | None, session_id: str) -> PrivatePortraitValidateSession: filters = [PrivatePortraitValidateSession.id == session_id] if user_id is not None: @@ -304,7 +314,7 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p raise HTTPException(status_code=400, detail=session.error_message) try: - existing_group = await _get_existing_active_group(db, project_id=session.project_id) + existing_group = await _get_existing_active_group(db, project_id=session.project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value) if existing_group: session.remote_group_id = existing_group.remote_group_id session.status = PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value @@ -330,10 +340,11 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p id=generate_id(), user_id=session.user_id, project_id=session.project_id, + library_type=PrivatePortraitLibraryType.REAL_PERSON.value, remote_group_id=group_id, remote_group_name=remote_group_name, remote_project_name=session.remote_project_name, - group_type=PRIVATE_PORTRAIT_GROUP_TYPE, + group_type=PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE, status=PrivatePortraitAssetGroupStatus.ACTIVE.value, raw_response_json=_json(resp), ) @@ -347,7 +358,7 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p await refresh_project_counters(db, [session.project_id]) await db.flush() await db.refresh(session) - log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, group_id=group.id, detail={"remote_group_id": group_id, "remote_project_name": session.remote_project_name}) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, group_id=group.id, detail={"remote_group_id": group_id, "remote_project_name": session.remote_project_name, "library_type": PrivatePortraitLibraryType.REAL_PERSON.value}) return session except Exception as exc: session.status = PrivatePortraitValidateSessionStatus.FAILED.value @@ -358,51 +369,67 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_FAILED.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, exc=exc) raise -async def get_project_active_group(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitAssetGroup: - project = await get_user_project(db, user_id=user_id, project_id=project_id) + +async def get_project_active_group(db: AsyncSession, *, user_id: str, project_id: str, library_type: str | None = None) -> PrivatePortraitAssetGroup: + project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type) if project.status != PrivatePortraitProjectStatus.ACTIVE.value: - raise HTTPException(status_code=400, detail="请先完成真人授权认证,再上传素材") - result = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.user_id == user_id, PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value, PrivatePortraitAssetGroup.deleted_at.is_(None)).order_by(PrivatePortraitAssetGroup.created_at.desc()).limit(1)) + detail = "请先完成真人授权认证,再上传素材" if project.library_type == PrivatePortraitLibraryType.REAL_PERSON.value else "虚拟人像素材组尚未创建成功,不能上传素材" + raise HTTPException(status_code=400, detail=detail) + result = await db.execute( + select(PrivatePortraitAssetGroup) + .where( + PrivatePortraitAssetGroup.user_id == user_id, + PrivatePortraitAssetGroup.project_id == project_id, + PrivatePortraitAssetGroup.library_type == project.library_type, + PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value, + PrivatePortraitAssetGroup.deleted_at.is_(None), + ) + .order_by(PrivatePortraitAssetGroup.created_at.desc()) + .limit(1) + ) group = result.scalar_one_or_none() if not group: - raise HTTPException(status_code=400, detail="请先完成真人授权认证,再上传素材") + raise HTTPException(status_code=400, detail="项目没有可用的远程素材组") return group -async def create_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate) -> PrivatePortraitAsset: - project = await get_user_project(db, user_id=user_id, project_id=project_id) +async def create_asset( + db: AsyncSession, + *, + user_id: str, + project_id: str, + payload: PrivatePortraitAssetCreate, + library_type: str | None = None, +) -> PrivatePortraitAsset: + _assert_enabled_asset_type(payload.asset_type) + project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type) if project.status != PrivatePortraitProjectStatus.ACTIVE.value: - raise HTTPException(status_code=400, detail="项目正在真人认证或认证未通过,不能上传素材") - if payload.asset_type != PrivatePortraitAssetType.IMAGE.value: - raise HTTPException(status_code=400, detail="第一版真人素材库仅开放 Image 图片素材") - user = await _lock_user_for_upload(db, user_id=user_id) - limit = int(getattr(user, "private_portrait_image_limit", PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT) or 0) - if limit <= 0: - log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_REJECT_DISABLED.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, message="用户真人素材模块未启用") - raise HTTPException(status_code=403, detail="真人素材库未启用") - current_count = await count_user_counting_image_assets(db, user_id=user_id) - if current_count >= limit: - log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_REJECT_MAX_LIMIT.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, detail={"current_count": current_count, "limit": limit}) - raise HTTPException(status_code=400, detail=f"你的真人素材库最多可上传 {limit} 张图片,请删除已有素材后再上传") + raise HTTPException(status_code=400, detail="项目未激活,不能上传素材") - group = await get_project_active_group(db, user_id=user_id, project_id=project.id) + limit, current_count = await ensure_private_portrait_asset_quota_available(db, user_id=user_id, project_id=project_id, library_type=project.library_type, asset_type=payload.asset_type) + group = await get_project_active_group(db, user_id=user_id, project_id=project.id, library_type=project.library_type) public_url = _public_url(payload.url) asset = PrivatePortraitAsset( id=generate_id(), user_id=user_id, project_id=project.id, group_id=group.id, + library_type=project.library_type, remote_group_id=group.remote_group_id, remote_project_name=project.remote_project_name, asset_type=payload.asset_type, name=payload.name, source_url=public_url, preview_url=payload.url, + video_duration=payload.video_duration, + video_cover_url=payload.video_cover_url, + file_size=payload.file_size, + mime_type=payload.mime_type, status=PrivatePortraitAssetStatus.CREATING.value, ) db.add(asset) await db.flush() - log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"limit": limit, "current_count": current_count, "remote_project_name": project.remote_project_name}) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"asset_limit": limit, "used_asset_count": current_count, "library_type": project.library_type, "asset_type": payload.asset_type, "remote_project_name": project.remote_project_name}) try: remote_resp = await ArkPrivateAssetClient().create_asset(project_name=project.remote_project_name, group_id=group.remote_group_id, url=public_url, asset_type=payload.asset_type, name=payload.name) remote_asset_id = remote_resp.get("Id") or remote_resp.get("AssetId") or remote_resp.get("assetId") @@ -411,14 +438,12 @@ async def create_asset(db: AsyncSession, *, user_id: str, project_id: str, paylo now = datetime.now(timezone.utc) asset.remote_asset_id = remote_asset_id asset.status = PrivatePortraitAssetStatus.PROCESSING.value - asset.next_poll_at = now + timedelta(seconds=PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS) + asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type)) asset.raw_response_json = _json(remote_resp) await refresh_project_counters(db, [project.id]) await db.flush() - # created_at / updated_at 来自数据库默认值或 onupdate,flush 后可能处于 expired 状态。 - # 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。 await db.refresh(asset) - log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"remote_asset_id": remote_asset_id, "remote_project_name": project.remote_project_name}) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"remote_asset_id": remote_asset_id, "remote_project_name": project.remote_project_name, "library_type": project.library_type, "asset_type": asset.asset_type}) return asset except Exception as exc: asset.status = PrivatePortraitAssetStatus.FAILED.value @@ -434,11 +459,11 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id: filters.append(PrivatePortraitAsset.user_id == user_id) asset = (await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))).scalar_one_or_none() if not asset: - raise HTTPException(status_code=404, detail="真人素材不存在") + raise HTTPException(status_code=404, detail="私域人像素材不存在") if asset.deleted_at is not None: - raise HTTPException(status_code=400, detail="真人素材已删除") + raise HTTPException(status_code=400, detail="私域人像素材已删除") if not asset.remote_asset_id: - raise HTTPException(status_code=400, detail="真人素材尚未创建远程 Asset") + raise HTTPException(status_code=400, detail="私域人像素材尚未创建远程 Asset") source = PrivatePortraitEventSource.CELERY.value if user_id is None else PrivatePortraitEventSource.API.value log_operation_event( @@ -449,12 +474,7 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id: user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, - detail={ - "status": asset.status, - "poll_count": int(asset.poll_count or 0), - "remote_asset_id": asset.remote_asset_id, - "remote_project_name": asset.remote_project_name, - }, + detail={"status": asset.status, "poll_count": int(asset.poll_count or 0), "remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type}, ) try: remote_resp = await ArkPrivateAssetClient(for_celery=(user_id is None)).get_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id) @@ -467,24 +487,15 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id: asset.status = status asset.remote_url = remote_resp.get("URL") or remote_resp.get("url") or asset.remote_url asset.moderation_json = _json(remote_resp.get("Moderation") or remote_resp.get("moderation")) + max_count = _poll_max_count(asset.asset_type) - if asset.status == PrivatePortraitAssetStatus.PROCESSING.value and asset.poll_count >= PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT: + if asset.status == PrivatePortraitAssetStatus.PROCESSING.value and asset.poll_count >= max_count: asset.status = PrivatePortraitAssetStatus.FAILED.value asset.error_message = "素材入库轮询超时" asset.next_poll_at = None - log_operation_event( - domain=DOMAIN, - event_type=PrivatePortraitEventType.ASSET_POLL_TIMEOUT.value, - event_status=PrivatePortraitEventStatus.FAILED.value, - source=source, - user_id=asset.user_id, - project_id=asset.project_id, - asset_id=asset.id, - detail={"poll_count": asset.poll_count, "max_count": PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT, "remote_asset_id": asset.remote_asset_id}, - error=asset.error_message, - ) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_POLL_TIMEOUT.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=source, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"poll_count": asset.poll_count, "max_count": max_count, "remote_asset_id": asset.remote_asset_id, "library_type": asset.library_type, "asset_type": asset.asset_type}, error=asset.error_message) elif asset.status == PrivatePortraitAssetStatus.PROCESSING.value: - asset.next_poll_at = now + timedelta(seconds=PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS) + asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type)) else: asset.next_poll_at = None @@ -493,23 +504,25 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id: await refresh_project_counters(db, [asset.project_id]) await db.flush() await db.refresh(asset) - log_operation_event( - domain=DOMAIN, - event_type=PrivatePortraitEventType.ASSET_SYNC_SUCCESS.value, - event_status=PrivatePortraitEventStatus.SUCCESS.value, - source=source, - user_id=asset.user_id, - project_id=asset.project_id, - asset_id=asset.id, - detail={"status": asset.status, "remote_asset_id": asset.remote_asset_id, "next_poll_at": asset.next_poll_at, "poll_count": asset.poll_count}, - ) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_SYNC_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=source, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"status": asset.status, "remote_asset_id": asset.remote_asset_id, "next_poll_at": asset.next_poll_at, "poll_count": asset.poll_count, "library_type": asset.library_type, "asset_type": asset.asset_type}) return asset except Exception as exc: log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_SYNC_FAILED.value, source=source, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, exc=exc) raise -async def list_assets(db: AsyncSession, *, user_id: str | None, project_id: str | None = None, status: str | None = None, keyword: str | None = None, page: int = 1, page_size: int = 20) -> tuple[list[PrivatePortraitAsset], int, dict[str, str]]: +async def list_assets( + db: AsyncSession, + *, + user_id: str | None, + project_id: str | None = None, + status: str | None = None, + keyword: str | None = None, + page: int = 1, + page_size: int = 20, + library_type: str | None = None, + asset_type: str | None = None, +) -> tuple[list[PrivatePortraitAsset], int, dict[str, str]]: page = max(1, page) page_size = min(max(1, page_size), 100) filters = [PrivatePortraitAsset.deleted_at.is_(None)] @@ -517,6 +530,10 @@ async def list_assets(db: AsyncSession, *, user_id: str | None, project_id: str filters.append(PrivatePortraitAsset.user_id == user_id) if project_id: filters.append(PrivatePortraitAsset.project_id == project_id) + if library_type: + filters.append(PrivatePortraitAsset.library_type == library_type) + if asset_type: + filters.append(PrivatePortraitAsset.asset_type == asset_type) if status: filters.append(PrivatePortraitAsset.status == status) if keyword: @@ -532,15 +549,35 @@ async def list_assets(db: AsyncSession, *, user_id: str | None, project_id: str return assets, int(total or 0), project_name_map -async def list_selectable_assets(db: AsyncSession, *, user_id: str, project_id: str | None = None, keyword: str | None = None, page: int = 1, page_size: int = 20) -> tuple[list[PrivatePortraitSelectableAssetOut], int]: - assets, total, project_name_map = await list_assets(db, user_id=user_id, project_id=project_id, status=PrivatePortraitAssetStatus.ACTIVE.value, keyword=keyword, page=page, page_size=page_size) - return [PrivatePortraitSelectableAssetOut(id=asset.id, project_id=asset.project_id, project_name=project_name_map.get(asset.project_id, ""), name=asset.name, asset_type=asset.asset_type, preview_url=asset.preview_url or asset.remote_url, status=asset.status, created_at=asset.created_at) for asset in assets], total +async def list_selectable_assets(db: AsyncSession, *, user_id: str, project_id: str | None = None, keyword: str | None = None, page: int = 1, page_size: int = 20, library_type: str | None = None, asset_type: str | None = None) -> tuple[list[PrivatePortraitSelectableAssetOut], int]: + assets, total, project_name_map = await list_assets(db, user_id=user_id, project_id=project_id, status=PrivatePortraitAssetStatus.ACTIVE.value, keyword=keyword, page=page, page_size=page_size, library_type=library_type, asset_type=asset_type) + return [ + PrivatePortraitSelectableAssetOut( + id=asset.id, + project_id=asset.project_id, + project_name=project_name_map.get(asset.project_id, ""), + library_type=asset.library_type, + name=asset.name, + asset_type=asset.asset_type, + preview_url=asset.preview_url or asset.remote_url, + display_url=_asset_display_url(asset), + provider_url=_provider_url(asset), + video_duration=asset.video_duration, + video_cover_url=asset.video_cover_url, + status=asset.status, + created_at=asset.created_at, + ) + for asset in assets + ], total -async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str) -> PrivatePortraitAsset: - asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == user_id, PrivatePortraitAsset.deleted_at.is_(None)).limit(1))).scalar_one_or_none() +async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str, library_type: str | None = None) -> PrivatePortraitAsset: + filters = [PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == user_id, PrivatePortraitAsset.deleted_at.is_(None)] + if library_type: + filters.append(PrivatePortraitAsset.library_type == library_type) + asset = (await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))).scalar_one_or_none() if not asset: - raise HTTPException(status_code=404, detail="真人素材不存在") + raise HTTPException(status_code=404, detail="私域人像素材不存在") now = datetime.now(timezone.utc) asset.deleted_at = now asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value @@ -548,64 +585,30 @@ async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str) -> await refresh_project_counters(db, [asset.project_id]) await db.flush() await db.refresh(asset) - log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_LOCAL.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name}) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_LOCAL.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type}) return asset async def delete_asset_remote(db: AsyncSession, *, asset_id: str) -> None: asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id).limit(1))).scalar_one_or_none() if not asset: - log_operation_event( - domain=DOMAIN, - event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value, - event_status=PrivatePortraitEventStatus.SKIPPED.value, - source=PrivatePortraitEventSource.CELERY.value, - asset_id=asset_id, - message="远程删除跳过:本地素材不存在", - ) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.SKIPPED.value, source=PrivatePortraitEventSource.CELERY.value, asset_id=asset_id, message="远程删除跳过:本地素材不存在") return if not asset.remote_asset_id: asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value asset.remote_delete_error = None await db.flush() - log_operation_event( - domain=DOMAIN, - event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value, - event_status=PrivatePortraitEventStatus.SKIPPED.value, - source=PrivatePortraitEventSource.CELERY.value, - user_id=asset.user_id, - project_id=asset.project_id, - asset_id=asset.id, - message="远程删除跳过:素材没有 remote_asset_id", - ) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SKIPPED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, message="远程删除跳过:素材没有 remote_asset_id") return now = datetime.now(timezone.utc) - log_operation_event( - domain=DOMAIN, - event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value, - event_status=PrivatePortraitEventStatus.PENDING.value, - source=PrivatePortraitEventSource.CELERY.value, - user_id=asset.user_id, - project_id=asset.project_id, - asset_id=asset.id, - detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name}, - ) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type}) try: await ArkPrivateAssetClient(for_celery=True).delete_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id) asset.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value asset.remote_deleted_at = now asset.remote_delete_error = None - log_operation_event( - domain=DOMAIN, - event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value, - event_status=PrivatePortraitEventStatus.SUCCESS.value, - source=PrivatePortraitEventSource.CELERY.value, - user_id=asset.user_id, - project_id=asset.project_id, - asset_id=asset.id, - detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name}, - ) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type}) except Exception as exc: asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value @@ -619,45 +622,18 @@ async def _delete_asset_group_remote(db: AsyncSession, *, group: PrivatePortrait group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value group.remote_delete_error = None await db.flush() - log_operation_event( - domain=DOMAIN, - event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, - event_status=PrivatePortraitEventStatus.SKIPPED.value, - source=PrivatePortraitEventSource.CELERY.value, - user_id=group.user_id, - project_id=group.project_id, - group_id=group.id, - message="远程删除跳过:素材组没有 remote_group_id", - ) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SKIPPED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, message="远程删除跳过:素材组没有 remote_group_id") return client = client or ArkPrivateAssetClient(for_celery=True) now = datetime.now(timezone.utc) - log_operation_event( - domain=DOMAIN, - event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value, - event_status=PrivatePortraitEventStatus.PENDING.value, - source=PrivatePortraitEventSource.CELERY.value, - user_id=group.user_id, - project_id=group.project_id, - group_id=group.id, - detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name}, - ) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name, "library_type": group.library_type}) try: await client.delete_asset_group(project_name=group.remote_project_name, group_id=group.remote_group_id) group.status = PrivatePortraitAssetGroupStatus.REMOTE_DELETED.value group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value group.remote_deleted_at = now group.remote_delete_error = None - log_operation_event( - domain=DOMAIN, - event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, - event_status=PrivatePortraitEventStatus.SUCCESS.value, - source=PrivatePortraitEventSource.CELERY.value, - user_id=group.user_id, - project_id=group.project_id, - group_id=group.id, - detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name}, - ) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name, "library_type": group.library_type}) except Exception as exc: group.status = PrivatePortraitAssetGroupStatus.DELETE_FAILED.value group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value @@ -667,14 +643,7 @@ async def _delete_asset_group_remote(db: AsyncSession, *, group: PrivatePortrait async def delete_project_remote(db: AsyncSession, *, project_id: str) -> None: - log_operation_event( - domain=DOMAIN, - event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value, - event_status=PrivatePortraitEventStatus.PENDING.value, - source=PrivatePortraitEventSource.CELERY.value, - project_id=project_id, - message="开始远程删除真人素材项目资源", - ) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, project_id=project_id, message="开始远程删除私域人像素材项目资源") rows = await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.project_id == project_id)) for asset in rows.scalars().all(): await delete_asset_remote(db, asset_id=asset.id) @@ -682,14 +651,7 @@ async def delete_project_remote(db: AsyncSession, *, project_id: str) -> None: client = ArkPrivateAssetClient(for_celery=True) for group in groups.scalars().all(): await _delete_asset_group_remote(db, group=group, client=client) - log_operation_event( - domain=DOMAIN, - event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, - event_status=PrivatePortraitEventStatus.SUCCESS.value, - source=PrivatePortraitEventSource.CELERY.value, - project_id=project_id, - message="远程删除真人素材项目资源完成", - ) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, project_id=project_id, message="远程删除私域人像素材项目资源完成") await db.flush() @@ -707,13 +669,7 @@ async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int: .limit(limit) ) ids = [row[0] for row in rows.all()] - log_operation_event( - domain=DOMAIN, - event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_START.value, - event_status=PrivatePortraitEventStatus.PENDING.value, - source=PrivatePortraitEventSource.CELERY.value, - detail={"limit": limit, "matched_count": len(ids)}, - ) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, detail={"limit": limit, "matched_count": len(ids)}) success_count = 0 failed_count = 0 for asset_id in ids: @@ -722,38 +678,15 @@ async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int: success_count += 1 except Exception as exc: failed_count += 1 - log_operation_error( - domain=DOMAIN, - event_type=PrivatePortraitEventType.ASSET_POLL_FAILED.value, - source=PrivatePortraitEventSource.CELERY.value, - asset_id=asset_id, - exc=exc, - ) - log_operation_event( - domain=DOMAIN, - event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_DONE.value, - event_status=PrivatePortraitEventStatus.SUCCESS.value if failed_count == 0 else PrivatePortraitEventStatus.WARNING.value, - source=PrivatePortraitEventSource.CELERY.value, - detail={"matched_count": len(ids), "success_count": success_count, "failed_count": failed_count}, - ) + log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_POLL_FAILED.value, source=PrivatePortraitEventSource.CELERY.value, asset_id=asset_id, exc=exc) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_DONE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value if failed_count == 0 else PrivatePortraitEventStatus.WARNING.value, source=PrivatePortraitEventSource.CELERY.value, detail={"matched_count": len(ids), "success_count": success_count, "failed_count": failed_count}) return len(ids) async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[str, int]: - log_operation_event( - domain=DOMAIN, - event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_START.value, - event_status=PrivatePortraitEventStatus.PENDING.value, - source=PrivatePortraitEventSource.CELERY.value, - detail={"limit": limit}, - ) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, detail={"limit": limit}) statuses = [PrivatePortraitRemoteDeleteStatus.PENDING.value, PrivatePortraitRemoteDeleteStatus.FAILED.value] - asset_rows = await db.execute( - select(PrivatePortraitAsset.id) - .where(PrivatePortraitAsset.remote_delete_status.in_(statuses)) - .order_by(PrivatePortraitAsset.updated_at.asc()) - .limit(limit) - ) + asset_rows = await db.execute(select(PrivatePortraitAsset.id).where(PrivatePortraitAsset.remote_delete_status.in_(statuses)).order_by(PrivatePortraitAsset.updated_at.asc()).limit(limit)) asset_ids = [row[0] for row in asset_rows.all()] for asset_id in asset_ids: await delete_asset_remote(db, asset_id=asset_id) @@ -761,12 +694,7 @@ async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[s remaining = max(0, limit - len(asset_ids)) group_count = 0 if remaining > 0: - group_rows = await db.execute( - select(PrivatePortraitAssetGroup) - .where(PrivatePortraitAssetGroup.remote_delete_status.in_(statuses)) - .order_by(PrivatePortraitAssetGroup.updated_at.asc()) - .limit(remaining) - ) + group_rows = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.remote_delete_status.in_(statuses)).order_by(PrivatePortraitAssetGroup.updated_at.asc()).limit(remaining)) client = ArkPrivateAssetClient(for_celery=True) groups = list(group_rows.scalars().all()) group_count = len(groups) @@ -774,12 +702,5 @@ async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[s await _delete_asset_group_remote(db, group=group, client=client) result = {"asset_count": len(asset_ids), "group_count": group_count, "total_count": len(asset_ids) + group_count} - log_operation_event( - domain=DOMAIN, - event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_DONE.value, - event_status=PrivatePortraitEventStatus.SUCCESS.value, - source=PrivatePortraitEventSource.CELERY.value, - detail=result, - ) + log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_DONE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, detail=result) return result - diff --git a/video-gen-api/app/services/private_portrait/project_service.py b/video-gen-api/app/services/private_portrait/project_service.py index 522f2c04..94f537a2 100644 --- a/video-gen-api/app/services/private_portrait/project_service.py +++ b/video-gen-api/app/services/private_portrait/project_service.py @@ -11,9 +11,11 @@ from app.enums.private_portrait import ( PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME, PrivatePortraitAssetGroupStatus, PrivatePortraitAssetStatus, + PrivatePortraitAssetType, PrivatePortraitEventSource, PrivatePortraitEventStatus, PrivatePortraitEventType, + PrivatePortraitLibraryType, PrivatePortraitProjectStatus, PrivatePortraitRemoteDeleteStatus, ) @@ -27,16 +29,24 @@ DOMAIN = "private_portrait" def _safe_slug(value: str, *, max_length: int = 80) -> str: value = (value or "").strip().lower() - # 先保留常见英文数字连字符;中文等字符统一转 _,仅用于本地项目 slug。 value = re.sub(r"[^a-z0-9_-]+", "_", value) value = re.sub(r"_+", "_", value).strip("_-") return (value[:max_length] or "project") +def _status_for_created_project(library_type: str) -> str: + if library_type == PrivatePortraitLibraryType.REAL_PERSON.value: + return PrivatePortraitProjectStatus.VALIDATING.value + if library_type == PrivatePortraitLibraryType.AIGC_VIRTUAL.value: + return PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value + raise HTTPException(status_code=400, detail="library_type 不支持") + + def project_to_out(project: PrivatePortraitProject, *, include_user: bool = False) -> PrivatePortraitProjectOut: return PrivatePortraitProjectOut( id=project.id, user_id=project.user_id if include_user else None, + library_type=project.library_type, name=project.name, name_slug=project.name_slug, remote_project_name=project.remote_project_name, @@ -44,37 +54,57 @@ def project_to_out(project: PrivatePortraitProject, *, include_user: bool = Fals status=project.status, asset_group_count=project.asset_group_count or 0, asset_count=project.asset_count or 0, + image_asset_count=getattr(project, "image_asset_count", 0) or 0, + video_asset_count=getattr(project, "video_asset_count", 0) or 0, active_asset_count=project.active_asset_count or 0, + active_image_asset_count=getattr(project, "active_image_asset_count", 0) or 0, + active_video_asset_count=getattr(project, "active_video_asset_count", 0) or 0, last_used_at=project.last_used_at, created_at=project.created_at, updated_at=project.updated_at, ) -async def get_user_project(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitProject: - result = await db.execute( - select(PrivatePortraitProject).where( - PrivatePortraitProject.id == project_id, - PrivatePortraitProject.user_id == user_id, - PrivatePortraitProject.deleted_at.is_(None), - ).limit(1) - ) +async def get_user_project( + db: AsyncSession, + *, + user_id: str, + project_id: str, + library_type: str | None = None, +) -> PrivatePortraitProject: + filters = [ + PrivatePortraitProject.id == project_id, + PrivatePortraitProject.user_id == user_id, + PrivatePortraitProject.deleted_at.is_(None), + ] + if library_type: + filters.append(PrivatePortraitProject.library_type == library_type) + result = await db.execute(select(PrivatePortraitProject).where(*filters).limit(1)) project = result.scalar_one_or_none() if not project: - raise HTTPException(status_code=404, detail="真人素材项目不存在") + raise HTTPException(status_code=404, detail="私域人像素材项目不存在") return project -async def create_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitProjectCreate) -> PrivatePortraitProject: +async def create_project( + db: AsyncSession, + *, + user_id: str, + payload: PrivatePortraitProjectCreate, + library_type: str = PrivatePortraitLibraryType.REAL_PERSON.value, + status: str | None = None, + remote_project_name: str = PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME, +) -> PrivatePortraitProject: slug = _safe_slug(payload.name) project = PrivatePortraitProject( id=generate_id(), user_id=user_id, + library_type=library_type, name=payload.name.strip(), name_slug=slug, - remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME, + remote_project_name=remote_project_name, description=payload.description, - status=PrivatePortraitProjectStatus.VALIDATING.value, + status=status or _status_for_created_project(library_type), ) db.add(project) await db.flush() @@ -85,35 +115,38 @@ async def create_project(db: AsyncSession, *, user_id: str, payload: PrivatePort source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, - message="创建待认证真人素材项目", - detail={"name": project.name, "remote_project_name": project.remote_project_name, "status": project.status}, + message="创建私域人像素材项目", + detail={"name": project.name, "library_type": library_type, "remote_project_name": project.remote_project_name, "status": project.status}, ) return project -async def update_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate) -> PrivatePortraitProject: - project = await get_user_project(db, user_id=user_id, project_id=project_id) +async def update_project( + db: AsyncSession, + *, + user_id: str, + project_id: str, + payload: PrivatePortraitProjectUpdate, + library_type: str | None = None, +) -> PrivatePortraitProject: + project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type) before = { "name": project.name, "name_slug": project.name_slug, "remote_project_name": project.remote_project_name, "description": project.description, "status": project.status, + "library_type": project.library_type, } if payload.name is not None: new_name = payload.name.strip() if new_name and new_name != project.name: project.name = new_name project.name_slug = _safe_slug(new_name) - project.remote_project_name = PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME if payload.description is not None: project.description = payload.description if payload.status is not None: - allowed_statuses = { - PrivatePortraitProjectStatus.VALIDATING.value, - PrivatePortraitProjectStatus.ACTIVE.value, - PrivatePortraitProjectStatus.VALIDATE_FAILED.value, - } + allowed_statuses = {item.value for item in PrivatePortraitProjectStatus if item != PrivatePortraitProjectStatus.DELETED} if payload.status not in allowed_statuses: raise HTTPException(status_code=400, detail="项目状态不支持") project.status = payload.status @@ -124,6 +157,7 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay "remote_project_name": project.remote_project_name, "description": project.description, "status": project.status, + "library_type": project.library_type, } log_operation_event( domain=DOMAIN, @@ -132,7 +166,7 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, - message="更新真人素材项目", + message="更新私域人像素材项目", detail={"before": before, "after": after}, ) return project @@ -146,18 +180,27 @@ async def list_projects( page_size: int = 20, keyword: str | None = None, status: str | None = None, + library_type: str | None = None, ) -> tuple[list[PrivatePortraitProject], int]: page = max(1, page) page_size = min(max(1, page_size), 100) filters = [PrivatePortraitProject.deleted_at.is_(None)] if user_id: filters.append(PrivatePortraitProject.user_id == user_id) + if library_type: + filters.append(PrivatePortraitProject.library_type == library_type) if keyword: filters.append(PrivatePortraitProject.name.ilike(f"%{keyword.strip()}%")) if status: filters.append(PrivatePortraitProject.status == status) total = (await db.execute(select(func.count(PrivatePortraitProject.id)).where(*filters))).scalar_one() - result = await db.execute(select(PrivatePortraitProject).where(*filters).order_by(PrivatePortraitProject.created_at.desc()).offset((page - 1) * page_size).limit(page_size)) + result = await db.execute( + select(PrivatePortraitProject) + .where(*filters) + .order_by(PrivatePortraitProject.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) return list(result.scalars().all()), int(total or 0) @@ -174,27 +217,73 @@ async def refresh_project_counters(db: AsyncSession, project_ids: list[str]) -> select( PrivatePortraitAsset.project_id, func.count(PrivatePortraitAsset.id), + func.sum(case((PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.IMAGE.value, 1), else_=0)), + func.sum(case((PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.VIDEO.value, 1), else_=0)), func.sum(case((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value, 1), else_=0)), + func.sum(case(((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value) & (PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.IMAGE.value), 1), else_=0)), + func.sum(case(((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value) & (PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.VIDEO.value), 1), else_=0)), ) .where(PrivatePortraitAsset.project_id.in_(project_ids), PrivatePortraitAsset.deleted_at.is_(None)) .group_by(PrivatePortraitAsset.project_id) ) group_count_map = {pid: int(count or 0) for pid, count in group_rows.all()} - asset_count_map: dict[str, tuple[int, int]] = {} - for pid, total, active_total in asset_rows.all(): - asset_count_map[pid] = (int(total or 0), int(active_total or 0)) + asset_count_map: dict[str, tuple[int, int, int, int, int, int]] = {} + for pid, total, image_total, video_total, active_total, active_image_total, active_video_total in asset_rows.all(): + asset_count_map[pid] = ( + int(total or 0), + int(image_total or 0), + int(video_total or 0), + int(active_total or 0), + int(active_image_total or 0), + int(active_video_total or 0), + ) for pid in project_ids: - total, active_total = asset_count_map.get(pid, (0, 0)) - await db.execute(update(PrivatePortraitProject).where(PrivatePortraitProject.id == pid).values(asset_group_count=group_count_map.get(pid, 0), asset_count=total, active_asset_count=active_total)) + total, image_total, video_total, active_total, active_image_total, active_video_total = asset_count_map.get(pid, (0, 0, 0, 0, 0, 0)) + await db.execute( + update(PrivatePortraitProject) + .where(PrivatePortraitProject.id == pid) + .values( + asset_group_count=group_count_map.get(pid, 0), + asset_count=total, + image_asset_count=image_total, + video_asset_count=video_total, + active_asset_count=active_total, + active_image_asset_count=active_image_total, + active_video_asset_count=active_video_total, + ) + ) -async def soft_delete_project(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitProject: - project = await get_user_project(db, user_id=user_id, project_id=project_id) +async def soft_delete_project( + db: AsyncSession, + *, + user_id: str, + project_id: str, + library_type: str | None = None, +) -> PrivatePortraitProject: + project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type) now = datetime.now(timezone.utc) project.deleted_at = now project.status = PrivatePortraitProjectStatus.DELETED.value - await db.execute(update(PrivatePortraitAsset).where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None)).values(deleted_at=now, status=PrivatePortraitAssetStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)) - await db.execute(update(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.deleted_at.is_(None)).values(deleted_at=now, status=PrivatePortraitAssetGroupStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)) + await db.execute( + update(PrivatePortraitAsset) + .where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None)) + .values(deleted_at=now, status=PrivatePortraitAssetStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value) + ) + await db.execute( + update(PrivatePortraitAssetGroup) + .where(PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.deleted_at.is_(None)) + .values(deleted_at=now, status=PrivatePortraitAssetGroupStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value) + ) await db.flush() - log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, message="本地软删真人素材项目", detail={"remote_project_name": project.remote_project_name}) + log_operation_event( + domain=DOMAIN, + event_type=PrivatePortraitEventType.PROJECT_DELETE.value, + event_status=PrivatePortraitEventStatus.SUCCESS.value, + source=PrivatePortraitEventSource.API.value, + user_id=user_id, + project_id=project.id, + message="本地软删私域人像素材项目", + detail={"library_type": project.library_type, "remote_project_name": project.remote_project_name}, + ) return project diff --git a/video-gen-api/app/services/private_portrait/quota_service.py b/video-gen-api/app/services/private_portrait/quota_service.py new file mode 100644 index 00000000..77b43222 --- /dev/null +++ b/video-gen-api/app/services/private_portrait/quota_service.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from fastapi import HTTPException +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.private_portrait import ( + PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT, + PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES, + PrivatePortraitAssetStatus, + PrivatePortraitEventSource, + PrivatePortraitEventStatus, + PrivatePortraitEventType, + PrivatePortraitLibraryType, +) +from app.models.private_portrait import PrivatePortraitAsset +from app.models.user import User +from app.schemas.private_portrait import PrivatePortraitConfigOut +from app.services.operation_log_service import log_operation_event + +DOMAIN = "private_portrait" + +_COUNTING_STATUSES = { + PrivatePortraitAssetStatus.CREATING.value, + PrivatePortraitAssetStatus.PROCESSING.value, + PrivatePortraitAssetStatus.ACTIVE.value, +} +_COUNTING_LIBRARY_TYPES = { + PrivatePortraitLibraryType.REAL_PERSON.value, + PrivatePortraitLibraryType.AIGC_VIRTUAL.value, +} + + +async def get_user_or_404(db: AsyncSession, *, user_id: str, for_update: bool = False) -> User: + stmt = select(User).where(User.id == user_id).limit(1) + if for_update: + stmt = stmt.with_for_update() + user = (await db.execute(stmt)).scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + return user + + +def get_user_asset_limit_value(user: User) -> int: + return int(getattr(user, "private_portrait_asset_limit", PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT) or 0) + + +async def count_user_counting_assets(db: AsyncSession, *, user_id: str) -> int: + total = ( + await db.execute( + select(func.count(PrivatePortraitAsset.id)).where( + PrivatePortraitAsset.user_id == user_id, + PrivatePortraitAsset.library_type.in_(_COUNTING_LIBRARY_TYPES), + PrivatePortraitAsset.asset_type.in_(PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES), + PrivatePortraitAsset.deleted_at.is_(None), + PrivatePortraitAsset.status.in_(_COUNTING_STATUSES), + ) + ) + ).scalar_one() + return int(total or 0) + + +async def get_user_private_portrait_config(db: AsyncSession, *, user_id: str) -> PrivatePortraitConfigOut: + user = await get_user_or_404(db, user_id=user_id) + limit = get_user_asset_limit_value(user) + used = await count_user_counting_assets(db, user_id=user_id) + remaining = max(0, limit - used) if limit > 0 else 0 + return PrivatePortraitConfigOut( + enabled=limit > 0, + asset_limit=limit, + used_asset_count=used, + remaining_asset_count=remaining, + image_limit=limit, + used_image_count=used, + remaining_image_count=remaining, + ) + + +async def set_user_private_portrait_limit(db: AsyncSession, *, user_id: str, limit: int) -> User: + user = await get_user_or_404(db, user_id=user_id) + user.private_portrait_asset_limit = max(0, int(limit)) + await db.flush() + return user + + +async def ensure_private_portrait_asset_quota_available( + db: AsyncSession, + *, + user_id: str, + project_id: str | None = None, + library_type: str | None = None, + asset_type: str | None = None, +) -> tuple[int, int]: + user = await get_user_or_404(db, user_id=user_id, for_update=True) + limit = get_user_asset_limit_value(user) + log_operation_event( + domain=DOMAIN, + event_type=PrivatePortraitEventType.QUOTA_CHECK_START.value, + event_status=PrivatePortraitEventStatus.PENDING.value, + source=PrivatePortraitEventSource.SERVICE.value, + user_id=user_id, + project_id=project_id, + detail={"asset_limit": limit, "library_type": library_type, "asset_type": asset_type}, + ) + if limit <= 0: + log_operation_event( + domain=DOMAIN, + event_type=PrivatePortraitEventType.QUOTA_CHECK_DENY.value, + event_status=PrivatePortraitEventStatus.FAILED.value, + source=PrivatePortraitEventSource.SERVICE.value, + user_id=user_id, + project_id=project_id, + detail={"asset_limit": limit, "library_type": library_type, "asset_type": asset_type, "reason": "disabled"}, + message="用户私域人像素材库未启用", + ) + raise HTTPException(status_code=403, detail="私域人像素材库未启用") + + used = await count_user_counting_assets(db, user_id=user_id) + if used >= limit: + log_operation_event( + domain=DOMAIN, + event_type=PrivatePortraitEventType.QUOTA_CHECK_DENY.value, + event_status=PrivatePortraitEventStatus.FAILED.value, + source=PrivatePortraitEventSource.SERVICE.value, + user_id=user_id, + project_id=project_id, + detail={"asset_limit": limit, "used_asset_count": used, "library_type": library_type, "asset_type": asset_type, "reason": "max_limit"}, + message="用户私域人像素材总量已达上限", + ) + raise HTTPException(status_code=400, detail=f"你的私域人像素材库最多可上传 {limit} 个素材,请删除已有素材后再上传") + + log_operation_event( + domain=DOMAIN, + event_type=PrivatePortraitEventType.QUOTA_CHECK_PASS.value, + event_status=PrivatePortraitEventStatus.SUCCESS.value, + source=PrivatePortraitEventSource.SERVICE.value, + user_id=user_id, + project_id=project_id, + detail={"asset_limit": limit, "used_asset_count": used, "remaining_asset_count": max(0, limit - used), "library_type": library_type, "asset_type": asset_type}, + ) + return limit, used diff --git a/video-gen-api/app/services/private_portrait/real_person/__init__.py b/video-gen-api/app/services/private_portrait/real_person/__init__.py new file mode 100644 index 00000000..e4cf26b6 --- /dev/null +++ b/video-gen-api/app/services/private_portrait/real_person/__init__.py @@ -0,0 +1 @@ +from app.services.private_portrait.real_person.service import * diff --git a/video-gen-api/app/services/private_portrait/real_person/service.py b/video-gen-api/app/services/private_portrait/real_person/service.py new file mode 100644 index 00000000..8ed84d47 --- /dev/null +++ b/video-gen-api/app/services/private_portrait/real_person/service.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.private_portrait import PrivatePortraitLibraryType +from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitProjectCreate, PrivatePortraitProjectUpdate +from app.services.private_portrait.asset_service import create_asset, create_validate_session +from app.services.private_portrait.project_service import create_project, update_project + + +async def create_real_person_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitProjectCreate): + return await create_project(db, user_id=user_id, payload=payload, library_type=PrivatePortraitLibraryType.REAL_PERSON.value) + + +async def update_real_person_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate): + return await update_project(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.REAL_PERSON.value) + + +async def create_real_person_validate_session(db: AsyncSession, *, user_id: str, project_id: str, callback_redirect_url: str | None = None): + return await create_validate_session(db, user_id=user_id, project_id=project_id, callback_redirect_url=callback_redirect_url) + + +async def create_real_person_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate): + return await create_asset(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.REAL_PERSON.value) diff --git a/video-gen-api/app/services/private_portrait/reference_resolver.py b/video-gen-api/app/services/private_portrait/reference_resolver.py index b7613454..7f03eff2 100644 --- a/video-gen-api/app/services/private_portrait/reference_resolver.py +++ b/video-gen-api/app/services/private_portrait/reference_resolver.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.enums.private_portrait import ( PRIVATE_PORTRAIT_ASSET_URI_PREFIX, PrivatePortraitAssetStatus, + PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES, PrivatePortraitAssetType, PrivatePortraitEventSource, PrivatePortraitEventStatus, @@ -25,7 +26,6 @@ DOMAIN = "private_portrait" _ASSET_TYPE_TO_REFERENCE_TYPE = { PrivatePortraitAssetType.IMAGE.value: "image", PrivatePortraitAssetType.VIDEO.value: "video", - PrivatePortraitAssetType.AUDIO.value: "audio", } @@ -260,24 +260,26 @@ async def resolve_private_portrait_references( asset_id = str(_ref_get(ref, "private_asset_id") or "") if not asset_id: - raise HTTPException(status_code=400, detail="真人素材引用缺少 private_asset_id") + raise HTTPException(status_code=400, detail="私域人像素材引用缺少 private_asset_id") asset = asset_map.get(asset_id) if not asset: - raise HTTPException(status_code=400, detail="真人素材不存在") + raise HTTPException(status_code=400, detail="私域人像素材不存在") if asset.user_id != user_id: - raise HTTPException(status_code=403, detail="真人素材不属于当前用户") + raise HTTPException(status_code=403, detail="私域人像素材不属于当前用户") if asset.deleted_at is not None: - raise HTTPException(status_code=400, detail="真人素材已删除") + raise HTTPException(status_code=400, detail="私域人像素材已删除") + if asset.asset_type not in PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES: + raise HTTPException(status_code=400, detail="Audio 暂未开放,当前仅支持 Image/Video 私域素材") if asset.status != PrivatePortraitAssetStatus.ACTIVE.value: - raise HTTPException(status_code=400, detail=f"真人素材状态为 {asset.status},Active 后才可用于生成") + raise HTTPException(status_code=400, detail=f"私域人像素材状态为 {asset.status},Active 后才可用于生成") if not asset.remote_asset_id: - raise HTTPException(status_code=400, detail="真人素材缺少远程 AssetId") + raise HTTPException(status_code=400, detail="私域人像素材缺少远程 AssetId") expected_ref_type = _ASSET_TYPE_TO_REFERENCE_TYPE.get(asset.asset_type) ref_type = _normalize_ref_type(_ref_get(ref, "type")) if expected_ref_type and ref_type and ref_type != expected_ref_type: - raise HTTPException(status_code=400, detail=f"真人素材类型不匹配:引用为 {ref_type},素材为 {expected_ref_type}") + raise HTTPException(status_code=400, detail=f"私域人像素材类型不匹配:引用为 {ref_type},素材为 {expected_ref_type}") provider_url = f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}" _ref_set(ref, "source", PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value) diff --git a/video-gen-api/app/services/private_portrait/virtual/__init__.py b/video-gen-api/app/services/private_portrait/virtual/__init__.py new file mode 100644 index 00000000..1d270070 --- /dev/null +++ b/video-gen-api/app/services/private_portrait/virtual/__init__.py @@ -0,0 +1 @@ +from app.services.private_portrait.virtual.service import * diff --git a/video-gen-api/app/services/private_portrait/virtual/service.py b/video-gen-api/app/services/private_portrait/virtual/service.py new file mode 100644 index 00000000..7a9557d8 --- /dev/null +++ b/video-gen-api/app/services/private_portrait/virtual/service.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import json + +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.private_portrait import ( + PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME, + PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE, + PrivatePortraitAssetGroupStatus, + PrivatePortraitEventSource, + PrivatePortraitEventStatus, + PrivatePortraitEventType, + PrivatePortraitLibraryType, + PrivatePortraitProjectStatus, +) +from app.models.private_portrait import PrivatePortraitAssetGroup +from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitProjectUpdate, PrivatePortraitVirtualProjectCreate +from app.services.operation_log_service import log_operation_error, log_operation_event +from app.services.private_portrait.ark_client import ArkPrivateAssetClient +from app.services.private_portrait.asset_service import create_asset +from app.services.private_portrait.project_service import create_project, refresh_project_counters, update_project +from app.utils.id_gen import generate_id + +DOMAIN = "private_portrait" + + +def _json(data) -> str | None: + if data is None: + return None + return json.dumps(data, ensure_ascii=False, default=str) + + +def _remote_group_name(user_id: str, project_name: str) -> str: + safe_name = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in project_name.strip())[:80] + return f"virtual-{user_id}-{safe_name}"[:128] + + +async def create_virtual_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitVirtualProjectCreate): + project = await create_project( + db, + user_id=user_id, + payload=payload, # type: ignore[arg-type] + library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, + status=PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value, + remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME, + ) + remote_group_name = _remote_group_name(user_id, project.name) + log_operation_event( + domain=DOMAIN, + event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START.value, + event_status=PrivatePortraitEventStatus.PENDING.value, + source=PrivatePortraitEventSource.API.value, + user_id=user_id, + project_id=project.id, + detail={"remote_group_name": remote_group_name, "remote_project_name": project.remote_project_name, "group_type": PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE}, + ) + try: + resp = await ArkPrivateAssetClient().create_asset_group( + project_name=project.remote_project_name, + name=remote_group_name, + description=project.description, + group_type=PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE, + ) + remote_group_id = resp.get("Id") or resp.get("GroupId") or resp.get("groupId") + if not remote_group_id: + raise RuntimeError("CreateAssetGroup 未返回素材组 ID") + group = PrivatePortraitAssetGroup( + id=generate_id(), + user_id=user_id, + project_id=project.id, + library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, + remote_group_id=remote_group_id, + remote_group_name=remote_group_name, + remote_project_name=project.remote_project_name, + group_type=PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE, + status=PrivatePortraitAssetGroupStatus.ACTIVE.value, + raw_response_json=_json(resp), + ) + db.add(group) + project.status = PrivatePortraitProjectStatus.ACTIVE.value + await refresh_project_counters(db, [project.id]) + await db.flush() + await db.refresh(project) + log_operation_event( + domain=DOMAIN, + event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS.value, + event_status=PrivatePortraitEventStatus.SUCCESS.value, + source=PrivatePortraitEventSource.API.value, + user_id=user_id, + project_id=project.id, + group_id=group.id, + detail={"remote_group_id": remote_group_id, "remote_group_name": remote_group_name, "remote_project_name": project.remote_project_name}, + ) + return project + except Exception as exc: + project.status = PrivatePortraitProjectStatus.CREATE_GROUP_FAILED.value + await db.flush() + log_operation_error( + domain=DOMAIN, + event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED.value, + source=PrivatePortraitEventSource.API.value, + user_id=user_id, + project_id=project.id, + exc=exc, + ) + raise HTTPException(status_code=502, detail=f"创建火山虚拟人像素材组失败:{exc}") from exc + + +async def update_virtual_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate): + project = await update_project(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value) + # 远程同步失败不影响本地更新,记录日志便于排查。 + try: + # 只同步当前激活组。 + from app.services.private_portrait.asset_service import get_project_active_group + + group = await get_project_active_group(db, user_id=user_id, project_id=project_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value) + await ArkPrivateAssetClient().update_asset_group(project_name=project.remote_project_name, group_id=group.remote_group_id, name=group.remote_group_name, title=project.name, description=project.description) + except Exception as exc: + log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_GROUP_UPDATE_REMOTE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, exc=exc) + return project + + +async def create_virtual_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate): + return await create_asset(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value) diff --git a/video-gen-app/src/types/index.ts b/video-gen-app/src/types/index.ts index 501d6cf0..228f2d80 100644 --- a/video-gen-app/src/types/index.ts +++ b/video-gen-app/src/types/index.ts @@ -257,9 +257,14 @@ export interface AdminNotification { export interface PrivatePortraitConfig { enabled: boolean; - imageLimit: number; - usedImageCount: number; - remainingImageCount: number; + assetLimit: number; + usedAssetCount: number; + remainingAssetCount: number; + supportedAssetTypes?: string[]; + unsupportedAssetTypes?: string[]; + imageLimit?: number; + usedImageCount?: number; + remainingImageCount?: number; } export interface PrivatePortraitProject {