真人素材库V3

This commit is contained in:
2026-07-07 13:20:50 +08:00
parent d80f5c3577
commit 6ae5e0d663
31 changed files with 1421 additions and 606 deletions
+7 -4
View File
@@ -448,7 +448,7 @@ export async function deleteMenuConfig(id: string): Promise<void> {
// ── User Creation ─────────────────────────────────────── // ── 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<any> { 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<any> {
return api.post('/admin/users', data); return api.post('/admin/users', data);
} }
@@ -1017,22 +1017,25 @@ export async function getPreTestTemplateList(params: PreTestTemplateListParams):
} }
// ── Private Portrait Admin ──────────────────────────────── // ── Private Portrait Admin ────────────────────────────────
export async function adminGetPrivatePortraitProjects(params: { userId?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitProjectListOut> { export async function adminGetPrivatePortraitProjects(params: { userId?: string; libraryType?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitProjectListOut> {
const query = new URLSearchParams(); const query = new URLSearchParams();
query.set('page', String(params.page || 1)); query.set('page', String(params.page || 1));
query.set('page_size', String(params.pageSize || 20)); query.set('page_size', String(params.pageSize || 20));
if (params.userId) query.set('user_id', params.userId); 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.keyword) query.set('keyword', params.keyword);
if (params.status) query.set('status', params.status); if (params.status) query.set('status', params.status);
return api.get<PrivatePortraitProjectListOut>(`/admin/private-portrait/projects?${query.toString()}`); return api.get<PrivatePortraitProjectListOut>(`/admin/private-portrait/projects?${query.toString()}`);
} }
export async function adminGetPrivatePortraitAssets(params: { userId?: string; projectId?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitAssetListOut> { export async function adminGetPrivatePortraitAssets(params: { userId?: string; projectId?: string; libraryType?: string; assetType?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitAssetListOut> {
const query = new URLSearchParams(); const query = new URLSearchParams();
query.set('page', String(params.page || 1)); query.set('page', String(params.page || 1));
query.set('page_size', String(params.pageSize || 20)); query.set('page_size', String(params.pageSize || 20));
if (params.userId) query.set('user_id', params.userId); if (params.userId) query.set('user_id', params.userId);
if (params.projectId) query.set('project_id', params.projectId); 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.keyword) query.set('keyword', params.keyword);
if (params.status) query.set('status', params.status); if (params.status) query.set('status', params.status);
return api.get<PrivatePortraitAssetListOut>(`/admin/private-portrait/assets?${query.toString()}`); return api.get<PrivatePortraitAssetListOut>(`/admin/private-portrait/assets?${query.toString()}`);
@@ -1044,5 +1047,5 @@ export async function adminGetPrivatePortraitConfig(userId: string): Promise<Pri
} }
export async function adminUpdatePrivatePortraitConfig(userId: string, limit: number): Promise<PrivatePortraitConfig> { export async function adminUpdatePrivatePortraitConfig(userId: string, limit: number): Promise<PrivatePortraitConfig> {
return api.put<PrivatePortraitConfig>(`/admin/private-portrait/users/${userId}/config`, { private_portrait_image_limit: limit }); return api.put<PrivatePortraitConfig>(`/admin/private-portrait/users/${userId}/config`, { private_portrait_asset_limit: limit });
} }
@@ -1,20 +1,102 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Button, Card, Input, Space, Table, Tag, Typography, message } from 'antd'; import { Button, Card, Image, Input, Select, Space, Statistic, Table, Tag, Tooltip, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table'; 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 { adminGetPrivatePortraitAssets, adminGetPrivatePortraitProjects } from '../api';
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../types'; import type { PrivatePortraitAsset, PrivatePortraitProject } from '../types';
import { formatDate } from '../utils/formatDate';
const { Title, Text } = Typography; 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) => { const statusColor = (status?: string) => {
if (status === 'Active' || status === 'active') return 'green'; if (status === 'Active' || status === 'active') return 'green';
if (status === 'Processing') return 'blue'; if (status === 'Processing' || status === 'creating_remote_group' || status === 'validating' || status === 'creating') return 'blue';
if (status === 'Failed' || status === 'failed' || status === 'delete_failed') return 'red'; if (status === 'Failed' || status === 'failed' || status === 'delete_failed' || status === 'validate_failed' || status === 'create_group_failed') return 'red';
if (status?.includes('deleted')) return 'default'; if (status?.includes('deleted')) return 'default';
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 (
<div style={{ width: 72, height: 52, borderRadius: 10, overflow: 'hidden', background: '#f5f3ff', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
{cover ? <img src={cover} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : <PlayCircleOutlined style={{ fontSize: 24, color: '#8b5cf6' }} />}
{url ? <a href={url} target="_blank" rel="noreferrer" style={{ position: 'absolute', inset: 0 }} /> : null}
<PlayCircleOutlined style={{ position: 'absolute', color: '#fff', fontSize: 22, filter: 'drop-shadow(0 1px 3px rgba(0,0,0,.45))' }} />
</div>
);
}
if (url) return <Image width={72} height={52} src={url} style={{ objectFit: 'cover', borderRadius: 10 }} />;
return <div style={{ width: 72, height: 52, borderRadius: 10, background: '#f5f5f5' }} />;
};
const AdminPrivatePortraitProjects: React.FC = () => { const AdminPrivatePortraitProjects: React.FC = () => {
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]); const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]); const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
@@ -23,18 +105,36 @@ const AdminPrivatePortraitProjects: React.FC = () => {
const [loadingProjects, setLoadingProjects] = useState(false); const [loadingProjects, setLoadingProjects] = useState(false);
const [loadingAssets, setLoadingAssets] = useState(false); const [loadingAssets, setLoadingAssets] = useState(false);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [libraryType, setLibraryType] = useState('');
const [assetType, setAssetType] = useState('');
const [projectStatus, setProjectStatus] = useState('');
const [assetStatus, setAssetStatus] = useState('');
const [selectedProjectId, setSelectedProjectId] = useState<string | undefined>(); const [selectedProjectId, setSelectedProjectId] = useState<string | undefined>();
const [projectPage, setProjectPage] = useState(1); const [projectPage, setProjectPage] = useState(1);
const [assetPage, setAssetPage] = 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 () => { const loadProjects = async () => {
setLoadingProjects(true); setLoadingProjects(true);
try { 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 || []); setProjects(res.items || []);
setProjectTotal(res.total || 0); setProjectTotal(res.total || 0);
} catch (err: any) { } catch (err: any) {
message.error(err?.message || '加载真人素材项目失败'); message.error(err?.message || '加载私域人像素材项目失败');
} finally { } finally {
setLoadingProjects(false); setLoadingProjects(false);
} }
@@ -43,88 +143,111 @@ const AdminPrivatePortraitProjects: React.FC = () => {
const loadAssets = async () => { const loadAssets = async () => {
setLoadingAssets(true); setLoadingAssets(true);
try { 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 || []); setAssets(res.items || []);
setAssetTotal(res.total || 0); setAssetTotal(res.total || 0);
} catch (err: any) { } catch (err: any) {
message.error(err?.message || '加载真人素材失败'); message.error(err?.message || '加载私域人像素材失败');
} finally { } finally {
setLoadingAssets(false); setLoadingAssets(false);
} }
}; };
useEffect(() => { loadProjects(); }, [projectPage]); useEffect(() => { loadProjects(); }, [projectPage, filters.libraryType, filters.projectStatus]);
useEffect(() => { loadAssets(); }, [selectedProjectId, assetPage]); 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<PrivatePortraitProject> = [ const projectColumns: ColumnsType<PrivatePortraitProject> = [
{ title: '项目名称', dataIndex: 'name', width: 180, render: (v, r) => <Button type="link" onClick={() => { setSelectedProjectId(r.id); setAssetPage(1); }}>{v}</Button> }, { title: '项目名称', dataIndex: 'name', width: 200, fixed: 'left', render: (v, r) => <Button type="link" onClick={() => { setSelectedProjectId(r.id); setAssetPage(1); }}>{v}</Button> },
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code>{v || '-'}</Text> }, { title: '类型', dataIndex: 'libraryType', width: 110, render: (v) => <Tag color={libraryColor(v)}>{libraryLabel(v)}</Tag> },
{ title: '状态', dataIndex: 'status', width: 100, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> }, { title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
{ title: 'ProjectName', dataIndex: 'remoteProjectName', width: 240, render: (v) => <Text code copyable>{v || '-'}</Text> }, { title: '状态', dataIndex: 'status', width: 130, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
{ title: '素材', dataIndex: 'assetCount', width: 100 }, { title: '素材', width: 170, render: (_, r) => <Space size={4}><Tag> {r.assetCount || 0}</Tag><Tag color="blue"> {r.imageAssetCount || 0}</Tag><Tag color="geekblue"> {r.videoAssetCount || 0}</Tag></Space> },
{ title: 'Active', dataIndex: 'activeAssetCount', width: 100 }, { title: 'Active', dataIndex: 'activeAssetCount', width: 90 },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v) => v || '-' }, { title: 'ProjectName', dataIndex: 'remoteProjectName', width: 180, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: formatDate },
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: formatDate },
]; ];
const assetColumns: ColumnsType<PrivatePortraitAsset> = [ const assetColumns: ColumnsType<PrivatePortraitAsset> = [
{ title: '素材', dataIndex: 'name', width: 180, render: (v, r) => v || r.remoteAssetId || '-' }, { title: '预览', width: 100, fixed: 'left', render: (_, r) => <PreviewCell asset={r} /> },
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code>{v || '-'}</Text> }, { title: '素材名称', dataIndex: 'name', width: 180, render: (v, r) => <div><Text strong>{v || '未命名素材'}</Text><br /><Text type="secondary">{assetTypeLabel(r.assetType)}</Text></div> },
{ title: '本地项目', dataIndex: 'projectName', width: 160 }, { title: '库类型', dataIndex: 'libraryType', width: 110, render: (v) => <Tag color={libraryColor(v)}>{libraryLabel(v)}</Tag> },
{ title: 'AssetId', dataIndex: 'remoteAssetId', width: 240, render: (v) => <Text code copyable>{v}</Text> }, { title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
{ title: 'GroupId', dataIndex: 'remoteGroupId', width: 240, render: (v) => <Text code copyable>{v}</Text> }, { title: '本地项目', dataIndex: 'projectName', width: 160, render: (v) => v || '-' },
{ title: 'ProjectName', dataIndex: 'remoteProjectName', width: 240, render: (v) => <Text code copyable>{v || '-'}</Text> }, { title: '状态', dataIndex: 'status', width: 120, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
{ title: '类型', dataIndex: 'assetType', width: 90 }, { title: 'AssetId', dataIndex: 'remoteAssetId', width: 190, render: (v) => <Space><Text code>{shortId(v)}</Text>{v ? <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(v)} /> : null}</Space> },
{ title: '状态', dataIndex: 'status', width: 110, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> }, { title: 'GroupId', dataIndex: 'remoteGroupId', width: 190, render: (v) => <Space><Text code>{shortId(v)}</Text>{v ? <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(v)} /> : null}</Space> },
{ title: '错误', dataIndex: 'errorMessage', width: 240, ellipsis: true, render: (v) => v || '-' }, { title: '轮询', width: 160, render: (_, r) => <div><Text>{r.pollCount || 0}</Text><br /><Text type="secondary">{formatDate((r as any).nextPollAt)}</Text></div> },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v) => v || '-' }, { title: '错误', dataIndex: 'errorMessage', width: 240, ellipsis: true, render: (v) => v ? <Tooltip title={v}><Text type="danger">{v}</Text></Tooltip> : '-' },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: formatDate },
]; ];
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24, background: '#f7f7fb', minHeight: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}> <Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} align="start">
<div> <div>
<Title level={3} style={{ margin: 0 }}></Title> <Title level={3} style={{ margin: 0 }}></Title>
<Text type="secondary">Asset Group Asset </Text> <Text type="secondary">/</Text>
</div> </div>
<Space> <Space wrap>
<Input <Input allowClear prefix={<SearchOutlined />} placeholder="搜索项目/素材" value={keyword} onChange={(e) => setKeyword(e.target.value)} onPressEnter={searchAll} style={{ width: 240 }} />
allowClear <Select value={libraryType} onChange={(v) => { setLibraryType(v); setProjectPage(1); setAssetPage(1); }} options={LIBRARY_OPTIONS} style={{ width: 150 }} />
prefix={<SearchOutlined />} <Select value={assetType} onChange={(v) => { setAssetType(v); setAssetPage(1); }} options={ASSET_TYPE_OPTIONS} style={{ width: 130 }} />
placeholder="搜索项目/素材" <Button icon={<SearchOutlined />} type="primary" onClick={searchAll}></Button>
value={keyword} <Button onClick={resetFilters}></Button>
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => { setProjectPage(1); setAssetPage(1); loadProjects(); loadAssets(); }}
style={{ width: 260 }}
/>
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(); }}></Button> <Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(); }}></Button>
</Space> </Space>
</Space> </Space>
<Card title="真人素材项目组" style={{ marginBottom: 16 }}> <Space size={16} wrap style={{ marginBottom: 16 }}>
<Table <Card style={{ width: 180 }}><Statistic title="项目数" value={projectTotal} /></Card>
rowKey="id" <Card style={{ width: 180 }}><Statistic title="当前页素材" value={projectSummary.asset} /></Card>
columns={projectColumns} <Card style={{ width: 180 }}><Statistic title="图片" value={projectSummary.image} /></Card>
dataSource={projects} <Card style={{ width: 180 }}><Statistic title="视频" value={projectSummary.video} /></Card>
loading={loadingProjects} <Card style={{ width: 180 }}><Statistic title="Active" value={projectSummary.active} /></Card>
pagination={{ current: projectPage, pageSize: 20, total: projectTotal, onChange: setProjectPage }} </Space>
size="small"
scroll={{ x: 900 }} <Card title="项目组" style={{ marginBottom: 16, borderRadius: 14 }} extra={<Select value={projectStatus} onChange={(v) => { setProjectStatus(v); setProjectPage(1); }} options={PROJECT_STATUS_OPTIONS} style={{ width: 180 }} />}>
/> <Table rowKey="id" columns={projectColumns} dataSource={projects} loading={loadingProjects} pagination={{ current: projectPage, pageSize: PAGE_SIZE, total: projectTotal, onChange: setProjectPage }} size="small" scroll={{ x: 1350 }} />
</Card> </Card>
<Card <Card title={selectedProjectId ? '项目素材明细' : '全部素材明细'} style={{ borderRadius: 14 }} extra={<Space><Select value={assetStatus} onChange={(v) => { setAssetStatus(v); setAssetPage(1); }} options={ASSET_STATUS_OPTIONS} style={{ width: 170 }} />{selectedProjectId ? <Button size="small" onClick={() => setSelectedProjectId(undefined)}></Button> : null}</Space>}>
title={selectedProjectId ? '项目素材明细' : '全部素材明细'} <Table rowKey="id" columns={assetColumns} dataSource={assets} loading={loadingAssets} pagination={{ current: assetPage, pageSize: PAGE_SIZE, total: assetTotal, onChange: setAssetPage }} size="small" scroll={{ x: 1700 }} />
extra={selectedProjectId ? <Button size="small" onClick={() => setSelectedProjectId(undefined)}></Button> : null}
>
<Table
rowKey="id"
columns={assetColumns}
dataSource={assets}
loading={loadingAssets}
pagination={{ current: assetPage, pageSize: 20, total: assetTotal, onChange: setAssetPage }}
size="small"
scroll={{ x: 1600 }}
/>
</Card> </Card>
</div> </div>
); );
+21 -21
View File
@@ -189,7 +189,7 @@ const AdminUsers: React.FC = () => {
credits: values.credits || 0, credits: values.credits || 0,
user_type: userType, user_type: userType,
frontend_user_kind: values.frontend_user_kind || 'external', 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('用户创建成功'); message.success('用户创建成功');
setCreateModal(false); setCreateModal(false);
@@ -276,13 +276,13 @@ const AdminUsers: React.FC = () => {
const openPortraitModal = async (user: AdminUser) => { const openPortraitModal = async (user: AdminUser) => {
setPortraitLoading(true); setPortraitLoading(true);
setPortraitModal({ open: true, user, config: null }); setPortraitModal({ open: true, user, config: null });
portraitForm.setFieldsValue({ privatePortraitImageLimit: user.privatePortraitImageLimit ?? 5 }); portraitForm.setFieldsValue({ privatePortraitAssetLimit: user.privatePortraitAssetLimit ?? 5 });
try { try {
const config = await adminGetPrivatePortraitConfig(user.id); const config = await adminGetPrivatePortraitConfig(user.id);
portraitForm.setFieldsValue({ privatePortraitImageLimit: config.imageLimit }); portraitForm.setFieldsValue({ privatePortraitAssetLimit: config.assetLimit });
setPortraitModal({ open: true, user, config }); setPortraitModal({ open: true, user, config });
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '加载真人素材库配置失败'); message.error(e?.message || '加载私域人像素材库配置失败');
setPortraitModal({ open: false, user: null, config: null }); setPortraitModal({ open: false, user: null, config: null });
} finally { } finally {
setPortraitLoading(false); setPortraitLoading(false);
@@ -294,16 +294,16 @@ const AdminUsers: React.FC = () => {
if (!user) return; if (!user) return;
try { try {
const values = await portraitForm.validateFields(); const values = await portraitForm.validateFields();
const limit = Number(values.privatePortraitImageLimit ?? 0); const limit = Number(values.privatePortraitAssetLimit ?? 0);
setPortraitSaving(true); setPortraitSaving(true);
const config = await adminUpdatePrivatePortraitConfig(user.id, limit); const config = await adminUpdatePrivatePortraitConfig(user.id, limit);
message.success(limit > 0 ? `真人素材库已开启,限制 ${limit} ` : '真人素材库已关闭'); message.success(limit > 0 ? `私域人像素材库已开启,限制 ${limit} ` : '私域人像素材库已关闭');
setPortraitModal({ open: false, user: null, config }); setPortraitModal({ open: false, user: null, config });
portraitForm.resetFields(); portraitForm.resetFields();
load(); load();
} catch (e: any) { } catch (e: any) {
if (e?.errorFields) return; if (e?.errorFields) return;
message.error(e?.message || '保存真人素材库配置失败'); message.error(e?.message || '保存私域人像素材库配置失败');
} finally { } finally {
setPortraitSaving(false); setPortraitSaving(false);
} }
@@ -424,10 +424,10 @@ const AdminUsers: React.FC = () => {
render: (v: string | null | undefined) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary"></Typography.Text>, render: (v: string | null | undefined) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary"></Typography.Text>,
}] : []), }] : []),
...(!isAdminTab ? [{ ...(!isAdminTab ? [{
title: '真人素材库', dataIndex: 'privatePortraitImageLimit', width: 150, title: '私域人像素材库', dataIndex: 'privatePortraitAssetLimit', width: 150,
render: (v: number) => { render: (v: number) => {
const limit = Number(v || 0); const limit = Number(v || 0);
return limit > 0 ? <Tag color="purple">{limit} </Tag> : <Tag></Tag>; return limit > 0 ? <Tag color="purple">{limit} </Tag> : <Tag></Tag>;
}, },
}] : []), }] : []),
...(!isAdminTab ? [{ ...(!isAdminTab ? [{
@@ -493,7 +493,7 @@ const AdminUsers: React.FC = () => {
{!isAdminTab && ( {!isAdminTab && (
<Button type="link" size="small" icon={<PictureOutlined />} <Button type="link" size="small" icon={<PictureOutlined />}
onClick={() => openPortraitModal(r)}> onClick={() => openPortraitModal(r)}>
</Button> </Button>
)} )}
{!isAdminTab && ( {!isAdminTab && (
@@ -784,7 +784,7 @@ const AdminUsers: React.FC = () => {
</Modal> </Modal>
<Modal <Modal
title={<Space><PictureOutlined /> - {portraitModal.user?.username}</Space>} title={<Space><PictureOutlined /> - {portraitModal.user?.username}</Space>}
open={portraitModal.open} open={portraitModal.open}
confirmLoading={portraitSaving} confirmLoading={portraitSaving}
onOk={handleSavePortraitConfig} onOk={handleSavePortraitConfig}
@@ -797,17 +797,17 @@ const AdminUsers: React.FC = () => {
{portraitModal.config?.enabled ? <Tag color="purple"></Tag> : <Tag></Tag>} {portraitModal.config?.enabled ? <Tag color="purple"></Tag> : <Tag></Tag>}
</Typography.Text> </Typography.Text>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
{portraitModal.config?.usedImageCount ?? '-'} {portraitModal.config?.usedAssetCount ?? '-'}
{portraitModal.config?.enabled ? portraitModal.config.remainingImageCount : 0} {portraitModal.config?.enabled ? portraitModal.config.remainingAssetCount : 0}
</Typography.Text> </Typography.Text>
</Space> </Space>
</Card> </Card>
<Form form={portraitForm} layout="vertical"> <Form form={portraitForm} layout="vertical">
<Form.Item <Form.Item
name="privatePortraitImageLimit" name="privatePortraitAssetLimit"
label="真人素材库图片上限" label="私域人像素材总量上限"
extra="0 表示关闭真人素材库;大于 0 表示开启,并限制该用户所有真人素材图片总量。" extra="0 表示关闭私域人像素材库;大于 0 表示开启,并限制该用户所有私域人像素材总量。"
rules={[{ required: true, message: '请输入真人素材库图片上限' }]} rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
> >
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" /> <InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
</Form.Item> </Form.Item>
@@ -857,11 +857,11 @@ const AdminUsers: React.FC = () => {
)} )}
{createType === 'frontend' && ( {createType === 'frontend' && (
<Form.Item <Form.Item
name="private_portrait_image_limit" name="private_portrait_asset_limit"
label="真人素材库图片上限" label="私域人像素材总量上限"
initialValue={5} initialValue={5}
extra="0 表示关闭真人素材库;大于 0 表示开启并限制该用户所有真人素材图片总量。" extra="0 表示关闭私域人像素材库;大于 0 表示开启并限制该用户所有私域人像素材总量。"
rules={[{ required: true, message: '请输入真人素材库图片上限' }]} rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
> >
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" /> <InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
</Form.Item> </Form.Item>
+27 -5
View File
@@ -8,7 +8,7 @@ export interface User {
userType: string; userType: string;
allowedMenus?: string[] | null; allowedMenus?: string[] | null;
resourceCapacity?: ResourceCapacityUsage | null; resourceCapacity?: ResourceCapacityUsage | null;
privatePortraitImageLimit: number; privatePortraitAssetLimit: number;
} }
export interface CreditRecord { export interface CreditRecord {
@@ -194,7 +194,7 @@ export interface AdminUser {
lastLoginAt?: string; lastLoginAt?: string;
allowedMenus?: string[] | null; allowedMenus?: string[] | null;
resourceCapacity?: ResourceCapacityUsage | null; resourceCapacity?: ResourceCapacityUsage | null;
privatePortraitImageLimit: number; privatePortraitAssetLimit: number;
} }
export interface AdminStats { export interface AdminStats {
@@ -1125,14 +1125,20 @@ export interface HomeMaterialTextWatermarkPreviewResponse {
export interface PrivatePortraitConfig { export interface PrivatePortraitConfig {
enabled: boolean; enabled: boolean;
imageLimit: number; assetLimit: number;
usedImageCount: number; usedAssetCount: number;
remainingImageCount: number; remainingAssetCount: number;
supportedAssetTypes?: string[];
unsupportedAssetTypes?: string[];
imageLimit?: number;
usedImageCount?: number;
remainingImageCount?: number;
} }
export interface PrivatePortraitProject { export interface PrivatePortraitProject {
id: string; id: string;
userId?: string | null; userId?: string | null;
libraryType: string;
name: string; name: string;
nameSlug?: string | null; nameSlug?: string | null;
remoteProjectName?: string | null; remoteProjectName?: string | null;
@@ -1140,7 +1146,11 @@ export interface PrivatePortraitProject {
status: string; status: string;
assetGroupCount: number; assetGroupCount: number;
assetCount: number; assetCount: number;
imageAssetCount?: number;
videoAssetCount?: number;
activeAssetCount: number; activeAssetCount: number;
activeImageAssetCount?: number;
activeVideoAssetCount?: number;
lastUsedAt?: string | null; lastUsedAt?: string | null;
createdAt?: string | null; createdAt?: string | null;
updatedAt?: string | null; updatedAt?: string | null;
@@ -1174,6 +1184,7 @@ export interface PrivatePortraitAsset {
projectId: string; projectId: string;
projectName?: string | null; projectName?: string | null;
groupId: string; groupId: string;
libraryType: string;
remoteGroupId: string; remoteGroupId: string;
remoteAssetId?: string | null; remoteAssetId?: string | null;
remoteProjectName?: string | null; remoteProjectName?: string | null;
@@ -1181,7 +1192,13 @@ export interface PrivatePortraitAsset {
name?: string | null; name?: string | null;
sourceUrl: string; sourceUrl: string;
previewUrl?: string | null; previewUrl?: string | null;
displayUrl?: string | null;
providerUrl?: string | null;
remoteUrl?: string | null; remoteUrl?: string | null;
videoDuration?: number | null;
videoCoverUrl?: string | null;
fileSize?: number | null;
mimeType?: string | null;
status: string; status: string;
pollCount: number; pollCount: number;
remoteDeleteStatus: string; remoteDeleteStatus: string;
@@ -1201,9 +1218,14 @@ export interface PrivatePortraitSelectableAsset {
id: string; id: string;
projectId: string; projectId: string;
projectName: string; projectName: string;
libraryType: string;
name?: string | null; name?: string | null;
assetType: string; assetType: string;
previewUrl?: string | null; previewUrl?: string | null;
displayUrl?: string | null;
providerUrl?: string | null;
videoDuration?: number | null;
videoCoverUrl?: string | null;
status: string; status: string;
createdAt?: string | null; createdAt?: string | null;
} }
+33 -101
View File
@@ -10,127 +10,59 @@ from app.dependencies import get_admin_user, get_db
from app.models.user import User from app.models.user import User
from app.schemas.private_portrait import ( from app.schemas.private_portrait import (
PrivatePortraitAdminConfigUpdate, PrivatePortraitAdminConfigUpdate,
PrivatePortraitAdminStatsOut,
PrivatePortraitAssetListOut, PrivatePortraitAssetListOut,
PrivatePortraitConfigOut, PrivatePortraitConfigOut,
PrivatePortraitProjectListOut, PrivatePortraitProjectListOut,
) )
from app.services.operation_log import log_operation from app.services.operation_log import log_operation
from app.services.private_portrait.asset_service import ( from app.services.private_portrait.admin.asset_query_service import admin_list_assets
asset_to_out, from app.services.private_portrait.admin.project_query_service import admin_list_projects
get_user_private_portrait_config, from app.services.private_portrait.admin.stats_query_service import admin_get_private_portrait_stats
list_assets, from app.services.private_portrait.quota_service import get_user_private_portrait_config, set_user_private_portrait_limit
set_user_private_portrait_limit,
)
from app.services.private_portrait.project_service import list_projects, project_to_out
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: def _json_detail(data: dict[str, Any]) -> str:
return json.dumps(data, ensure_ascii=False, default=str) return json.dumps(data, ensure_ascii=False, default=str)
@router.get("/users/{user_id}/config", response_model=PrivatePortraitConfigOut) @router.get("/users/{user_id}/config", response_model=PrivatePortraitConfigOut, summary="管理后台:查看用户私域人像素材额度")
async def admin_get_private_portrait_config( async def admin_get_private_portrait_config(user_id: str, admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
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) config = await get_user_private_portrait_config(db, user_id=user_id)
await log_operation( 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")}))
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 return config
@router.put("/users/{user_id}/config", response_model=PrivatePortraitConfigOut) @router.put("/users/{user_id}/config", response_model=PrivatePortraitConfigOut, summary="管理后台:设置用户私域人像素材总量")
async def admin_update_private_portrait_config( async def admin_update_private_portrait_config(user_id: str, payload: PrivatePortraitAdminConfigUpdate, admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
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) 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) after = await get_user_private_portrait_config(db, user_id=user_id)
await log_operation( 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")}))
db, await db.commit()
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"),
}
),
)
return after return after
@router.get("/projects", response_model=PrivatePortraitProjectListOut) @router.get("/stats", response_model=PrivatePortraitAdminStatsOut, summary="管理后台:私域人像素材统计")
async def admin_list_private_portrait_projects( 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)):
page: int = Query(1, ge=1), stats = await admin_get_private_portrait_stats(db, user_id=user_id, library_type=library_type)
page_size: int = Query(20, ge=1, le=100), 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")}))
user_id: str | None = Query(None), return stats
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("/assets", response_model=PrivatePortraitAssetListOut) @router.get("/projects", response_model=PrivatePortraitProjectListOut, summary="管理后台:查询私域人像项目列表")
async def admin_list_private_portrait_assets( 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)):
page: int = Query(1, ge=1), 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)
page_size: int = Query(20, ge=1, le=100), 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)}))
user_id: str | None = Query(None), await db.commit()
project_id: str | None = Query(None), return PrivatePortraitProjectListOut(items=items, total=total, page=page, page_size=page_size)
keyword: str | None = Query(None),
status: str | None = Query(None),
admin: User = Depends(get_admin_user), @router.get("/assets", response_model=PrivatePortraitAssetListOut, summary="管理后台:查询私域人像素材列表")
db: AsyncSession = Depends(get_db), 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)
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, "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 log_operation( await db.commit()
db, return PrivatePortraitAssetListOut(items=items, total=total, page=page, page_size=page_size)
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)
+1
View File
@@ -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.admin import router as admin_module_router
from app.api.v1.material_admin import router as material_admin_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 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 = APIRouter()
api_router.include_router(auth_router) api_router.include_router(auth_router)
+2 -2
View File
@@ -174,7 +174,7 @@ async def create_user(
user_type=req.user_type, user_type=req.user_type,
frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value, frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value,
allowed_menus=req.allowed_menus, 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) user.credits = round(user.credits, 2)
db.add(user) db.add(user)
@@ -192,7 +192,7 @@ async def create_user(
"username": username, "username": username,
"user_type": req.user_type, "user_type": req.user_type,
"frontend_user_kind": user.frontend_user_kind, "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, "credits": user.credits,
"phone": user.phone, "phone": user.phone,
"email": user.email, "email": user.email,
+78 -75
View File
@@ -9,9 +9,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db from app.dependencies import get_current_user, get_db
from app.enums.private_portrait import ( from app.enums.private_portrait import (
PrivatePortraitAssetType,
PrivatePortraitEventSource, PrivatePortraitEventSource,
PrivatePortraitEventStatus, PrivatePortraitEventStatus,
PrivatePortraitEventType, PrivatePortraitEventType,
PrivatePortraitLibraryType,
PrivatePortraitProjectStatus, PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus, PrivatePortraitRemoteDeleteStatus,
) )
@@ -20,6 +22,7 @@ from app.models.user import User
from app.schemas.private_portrait import ( from app.schemas.private_portrait import (
PrivatePortraitAssetCreate, PrivatePortraitAssetCreate,
PrivatePortraitAssetListOut, PrivatePortraitAssetListOut,
PrivatePortraitAssetOut,
PrivatePortraitDeleteOut, PrivatePortraitDeleteOut,
PrivatePortraitConfigOut, PrivatePortraitConfigOut,
PrivatePortraitProjectCreate, PrivatePortraitProjectCreate,
@@ -30,13 +33,13 @@ from app.schemas.private_portrait import (
PrivatePortraitSelectableAssetListOut, PrivatePortraitSelectableAssetListOut,
PrivatePortraitValidateSessionCreate, PrivatePortraitValidateSessionCreate,
PrivatePortraitValidateSessionOut, PrivatePortraitValidateSessionOut,
PrivatePortraitEnumMetaOut,
build_private_portrait_enum_meta,
) )
from app.services.operation_log_service import log_operation_error, log_operation_event from app.services.operation_log_service import log_operation_error, log_operation_event
from app.services.private_portrait.asset_service import ( from app.services.private_portrait.asset_service import (
DOMAIN, DOMAIN,
asset_to_out, asset_to_out,
create_asset,
create_validate_session,
get_user_private_portrait_config, get_user_private_portrait_config,
get_validate_session, get_validate_session,
handle_validate_callback, handle_validate_callback,
@@ -47,100 +50,102 @@ from app.services.private_portrait.asset_service import (
validate_session_to_out, validate_session_to_out,
) )
from app.services.private_portrait.project_service import ( from app.services.private_portrait.project_service import (
create_project,
get_user_project, get_user_project,
list_projects, list_projects,
project_to_out, project_to_out,
refresh_project_counters, refresh_project_counters,
soft_delete_project, 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: 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( 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})
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: 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( 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})
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)): 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) 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)): 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) project = await create_real_person_project(db, user_id=current_user.id, payload=payload)
session = await create_validate_session( session = await create_real_person_validate_session(db, user_id=current_user.id, project_id=project.id, callback_redirect_url=payload.callback_redirect_url)
db, out = PrivatePortraitProjectCreateWithValidateOut(project=project_to_out(project), validate_session=validate_session_to_out(session), poll_interval_ms=2000)
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() await db.commit()
return out 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( async def list_private_portrait_projects(
page: int = Query(1, ge=1), page: int = Query(1, ge=1, description="页码,从 1 开始。"),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100。"),
keyword: str | None = Query(None), keyword: str | None = Query(None, description="项目名称模糊搜索。"),
status: str | None = Query(None), status: str | None = Query(None, description="项目状态,不传默认 active。"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
query_status = status or PrivatePortraitProjectStatus.ACTIVE.value 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 refresh_project_counters(db, [item.id for item in items])
await db.commit() 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) 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)): 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)): 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) out = project_to_out(project)
await db.commit() await db.commit()
return out 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)): 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 project_id_snapshot = project.id
await db.commit() await db.commit()
try: 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) 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)): 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) out = validate_session_to_out(session)
await db.commit() await db.commit()
return out 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)): 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)) 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)): 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 = dict(request.query_params)
params.pop("session_id", None) params.pop("session_id", None)
params.pop("redirect_url", None) params.pop("redirect_url", None)
session = await handle_validate_callback(db, session_id=session_id, query_params=params) session = await handle_validate_callback(db, session_id=session_id, query_params=params)
redirect_params = { redirect_params = {"session_id": session.id, "status": session.status, "resultCode": session.result_code or ""}
"session_id": session.id,
"status": session.status,
"resultCode": session.result_code or "",
}
if session.remote_group_id: if session.remote_group_id:
redirect_params["remote_group_id"] = 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} 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 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)): 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 asset_id_snapshot = asset.id
project_id_snapshot = asset.project_id project_id_snapshot = asset.project_id
out = asset_to_out(asset) out = asset_to_out(asset)
@@ -205,32 +206,34 @@ async def create_private_portrait_asset(project_id: str, payload: PrivatePortrai
return out return out
@router.get("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut) @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), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): 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) 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) 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)): 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: 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() 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) 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)): 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) 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) out = asset_to_out(asset)
await db.commit() await db.commit()
return out 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)): 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 asset_id_snapshot = asset.id
project_id_snapshot = asset.project_id project_id_snapshot = asset.project_id
await db.commit() 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) return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@router.get("/private-portrait/selectable-assets", response_model=PrivatePortraitSelectableAssetListOut) @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), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): 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) 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) return PrivatePortraitSelectableAssetListOut(items=items, total=total, page=page, page_size=page_size)
@@ -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="创建本地虚拟人像项目,并同步调用火山 CreateAssetGroupGroupType=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)
+37 -6
View File
@@ -2,14 +2,18 @@ from __future__ import annotations
from enum import Enum from enum import Enum
# 用户真人素材图片默认上限。users.private_portrait_image_limit = 0 表示关闭模块;>0 表示启用并限制总量。 # 用户私域人像素材默认上限。users.private_portrait_asset_limit = 0 表示关闭模块;>0 表示启用并限制总素材量。
PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT = 5 # 统计口径:真人 + 虚拟;图片 + 视频。音频当前业务暂不开放。
PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT = 5
# 火山 Ark 私域真人素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。 # 火山 Ark 私域素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。
# 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。 # 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME = "default" 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_VERIFY_TYPE = "real_time"
PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE = "10000" PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE = "10000"
PRIVATE_PORTRAIT_ASSET_URI_PREFIX = "asset://" 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" ARK_PRIVATE_PORTRAIT_HOST = "ark.cn-beijing.volcengineapi.com"
PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS = 20 PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS = 20
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS = 30
PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT = 60 PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT = 60
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT = 120
PRIVATE_PORTRAIT_ASSET_POLL_BATCH_SIZE = 50 PRIVATE_PORTRAIT_ASSET_POLL_BATCH_SIZE = 50
PRIVATE_PORTRAIT_REMOTE_DELETE_RECOVERY_BATCH_SIZE = 50 PRIVATE_PORTRAIT_REMOTE_DELETE_RECOVERY_BATCH_SIZE = 50
PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES = 30 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] = { PRIVATE_PORTRAIT_ACTION_QPS_LIMITS: dict[str, int] = {
"CreateVisualValidateSession": 3, "CreateVisualValidateSession": 3,
"GetVisualValidateResult": 3, "GetVisualValidateResult": 3,
"CreateAssetGroup": 10,
"CreateAsset": 1, "CreateAsset": 1,
"ListAssetGroups": 10, "ListAssetGroups": 10,
"ListAssets": 10, "ListAssets": 10,
@@ -44,6 +51,7 @@ PRIVATE_PORTRAIT_ACTION_QPS_LIMITS: dict[str, int] = {
class ArkPrivatePortraitAction(str, Enum): class ArkPrivatePortraitAction(str, Enum):
CREATE_VISUAL_VALIDATE_SESSION = "CreateVisualValidateSession" CREATE_VISUAL_VALIDATE_SESSION = "CreateVisualValidateSession"
GET_VISUAL_VALIDATE_RESULT = "GetVisualValidateResult" GET_VISUAL_VALIDATE_RESULT = "GetVisualValidateResult"
CREATE_ASSET_GROUP = "CreateAssetGroup"
CREATE_ASSET = "CreateAsset" CREATE_ASSET = "CreateAsset"
GET_ASSET = "GetAsset" GET_ASSET = "GetAsset"
LIST_ASSETS = "ListAssets" LIST_ASSETS = "ListAssets"
@@ -55,10 +63,17 @@ class ArkPrivatePortraitAction(str, Enum):
DELETE_ASSET_GROUP = "DeleteAssetGroup" DELETE_ASSET_GROUP = "DeleteAssetGroup"
class PrivatePortraitLibraryType(str, Enum):
REAL_PERSON = "real_person"
AIGC_VIRTUAL = "aigc_virtual"
class PrivatePortraitProjectStatus(str, Enum): class PrivatePortraitProjectStatus(str, Enum):
VALIDATING = "validating" VALIDATING = "validating"
ACTIVE = "active" ACTIVE = "active"
VALIDATE_FAILED = "validate_failed" VALIDATE_FAILED = "validate_failed"
CREATING_REMOTE_GROUP = "creating_remote_group"
CREATE_GROUP_FAILED = "create_group_failed"
DELETED = "deleted" DELETED = "deleted"
@@ -72,6 +87,7 @@ class PrivatePortraitValidateSessionStatus(str, Enum):
class PrivatePortraitAssetGroupStatus(str, Enum): class PrivatePortraitAssetGroupStatus(str, Enum):
CREATING = "creating"
ACTIVE = "active" ACTIVE = "active"
LOCAL_DELETED = "local_deleted" LOCAL_DELETED = "local_deleted"
REMOTE_DELETED = "remote_deleted" REMOTE_DELETED = "remote_deleted"
@@ -92,7 +108,13 @@ class PrivatePortraitAssetStatus(str, Enum):
class PrivatePortraitAssetType(str, Enum): class PrivatePortraitAssetType(str, Enum):
IMAGE = "Image" IMAGE = "Image"
VIDEO = "Video" VIDEO = "Video"
AUDIO = "Audio" AUDIO = "Audio" # 火山支持,但当前业务暂不开放。
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES = {
PrivatePortraitAssetType.IMAGE.value,
PrivatePortraitAssetType.VIDEO.value,
}
class PrivatePortraitRemoteDeleteStatus(str, Enum): class PrivatePortraitRemoteDeleteStatus(str, Enum):
@@ -125,9 +147,19 @@ class PrivatePortraitEventSource(str, Enum):
class PrivatePortraitEventType(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_CREATE = "PROJECT_CREATE"
PROJECT_UPDATE = "PROJECT_UPDATE" PROJECT_UPDATE = "PROJECT_UPDATE"
PROJECT_DELETE = "PROJECT_DELETE" 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 = "VALIDATE_SESSION_CREATE"
VALIDATE_SESSION_CREATE_FAILED = "VALIDATE_SESSION_CREATE_FAILED" VALIDATE_SESSION_CREATE_FAILED = "VALIDATE_SESSION_CREATE_FAILED"
VALIDATE_CALLBACK_RECEIVED = "VALIDATE_CALLBACK_RECEIVED" 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_SUCCESS = "PROJECT_DELETE_REMOTE_SUCCESS"
PROJECT_DELETE_REMOTE_FAILED = "PROJECT_DELETE_REMOTE_FAILED" PROJECT_DELETE_REMOTE_FAILED = "PROJECT_DELETE_REMOTE_FAILED"
TASK_DISPATCH_SUCCESS = "TASK_DISPATCH_SUCCESS" TASK_DISPATCH_SUCCESS = "TASK_DISPATCH_SUCCESS"
TASK_DISPATCH_FAILED = "TASK_DISPATCH_FAILED" TASK_DISPATCH_FAILED = "TASK_DISPATCH_FAILED"
@@ -2,12 +2,13 @@ from __future__ import annotations
from datetime import datetime 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 sqlalchemy.orm import Mapped, mapped_column
from app.enums.private_portrait import ( from app.enums.private_portrait import (
PrivatePortraitAssetStatus, PrivatePortraitAssetStatus,
PrivatePortraitAssetType, PrivatePortraitAssetType,
PrivatePortraitLibraryType,
PrivatePortraitRemoteDeleteStatus, PrivatePortraitRemoteDeleteStatus,
) )
from app.models.base import Base, SoftDeleteMixin, TimestampMixin from app.models.base import Base, SoftDeleteMixin, TimestampMixin
@@ -19,10 +20,13 @@ class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "private_portrait_assets" __tablename__ = "private_portrait_assets"
__table_args__ = ( __table_args__ = (
Index("uq_private_portrait_assets_remote_asset_id", "remote_asset_id", unique=True), 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_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_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_group_status_created", "group_id", "status", "created_at"),
Index("idx_private_portrait_assets_remote_project_name", "remote_project_name"), 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( Index(
"idx_private_portrait_assets_next_poll_status", "idx_private_portrait_assets_next_poll_status",
"next_poll_at", "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) 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) 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) 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_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_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) 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) preview_url: Mapped[str | None] = mapped_column(Text, nullable=True)
remote_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) 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( status: Mapped[str] = mapped_column(
String(32), String(32),
nullable=False, nullable=False,
@@ -6,8 +6,9 @@ from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.enums.private_portrait import ( from app.enums.private_portrait import (
PRIVATE_PORTRAIT_GROUP_TYPE, PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE,
PrivatePortraitAssetGroupStatus, PrivatePortraitAssetGroupStatus,
PrivatePortraitLibraryType,
PrivatePortraitRemoteDeleteStatus, PrivatePortraitRemoteDeleteStatus,
) )
from app.models.base import Base, SoftDeleteMixin, TimestampMixin from app.models.base import Base, SoftDeleteMixin, TimestampMixin
@@ -20,9 +21,11 @@ class PrivatePortraitAssetGroup(Base, TimestampMixin, SoftDeleteMixin):
__table_args__ = ( __table_args__ = (
Index("uq_private_portrait_asset_groups_remote_group_id", "remote_group_id", unique=True), 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_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_project_status", "project_id", "status"),
Index("idx_private_portrait_asset_groups_remote_delete_status", "remote_delete_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_remote_project_name", "remote_project_name"),
Index("idx_private_portrait_asset_groups_library_type", "library_type"),
Index( Index(
"uq_private_portrait_asset_groups_one_active_project", "uq_private_portrait_asset_groups_one_active_project",
"project_id", "project_id",
@@ -34,10 +37,17 @@ class PrivatePortraitAssetGroup(Base, TimestampMixin, SoftDeleteMixin):
id: Mapped[str] = mapped_column(String(32), primary_key=True) 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) 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) 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_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_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) 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( status: Mapped[str] = mapped_column(
String(32), String(32),
nullable=False, nullable=False,
@@ -5,15 +5,16 @@ from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column 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 from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin): class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
"""用户本地真人素材项目组。remote_project_name 是火山 ProjectName 快照。""" """用户本地私域人像素材项目组。remote_project_name 是火山 ProjectName 快照。"""
__tablename__ = "private_portrait_projects" __tablename__ = "private_portrait_projects"
__table_args__ = ( __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_status_created", "user_id", "status", "created_at"),
Index( Index(
"idx_private_portrait_projects_user_deleted", "idx_private_portrait_projects_user_deleted",
@@ -22,10 +23,19 @@ class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
postgresql_where=text("deleted_at IS NULL"), postgresql_where=text("deleted_at IS NULL"),
), ),
Index("idx_private_portrait_projects_remote_project_name", "remote_project_name"), 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) 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) 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: 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") 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 快照") 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_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") 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_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) last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+2 -2
View File
@@ -39,8 +39,8 @@ class User(Base, TimestampMixin):
) )
allowed_menus: Mapped[list | None] = mapped_column(JSON, nullable=True) allowed_menus: Mapped[list | None] = mapped_column(JSON, nullable=True)
# 真人素材库图片总量限制。0 表示关闭真人素材模块;>0 表示启用并限制用户所有真人素材图片总量。 # 私域人像素材总量限制。0 表示关闭模块;>0 表示启用并限制真人/虚拟、图片/视频素材总量。
private_portrait_image_limit: Mapped[int] = mapped_column( private_portrait_asset_limit: Mapped[int] = mapped_column(
Integer, default=5, server_default="5", nullable=False Integer, default=5, server_default="5", nullable=False
) )
+2 -2
View File
@@ -58,7 +58,7 @@ class AdminUserOut(BaseModel):
last_login_at: NaiveDatetimeOptional = None last_login_at: NaiveDatetimeOptional = None
allowed_menus: list | None = None allowed_menus: list | None = None
resource_capacity: ResourceCapacityUsageOut | None = None resource_capacity: ResourceCapacityUsageOut | None = None
private_portrait_image_limit: int = 5 private_portrait_asset_limit: int = 5
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -72,7 +72,7 @@ class CreateUserRequest(BaseModel):
user_type: str = Field(default="frontend", pattern="^(frontend|admin)$") user_type: str = Field(default="frontend", pattern="^(frontend|admin)$")
frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$") frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$")
allowed_menus: list | None = None 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): class UpdateFrontendUserKindRequest(BaseModel):
+102 -22
View File
@@ -4,36 +4,67 @@ from typing import Any
from pydantic import BaseModel, Field, field_validator 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 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): class PrivatePortraitConfigOut(BaseModel):
enabled: bool enabled: bool = Field(..., description="是否启用私域人像素材库。asset_limit > 0 表示启用。")
image_limit: int asset_limit: int = Field(..., description="私域人像素材总量限制:真人/虚拟共用,图片/视频共用,0 表示关闭。")
used_image_count: int used_asset_count: int = Field(..., description="当前占用额度的素材数量。统计 creating/Processing/Active 的 Image/Video。")
remaining_image_count: int 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): 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): class PrivatePortraitProjectCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=128) name: str = Field(..., min_length=1, max_length=128, description="项目组名称。")
description: str | None = Field(None, max_length=2000) description: str | None = Field(None, max_length=2000, description="项目组描述。")
callback_redirect_url: str | None = Field(None, 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): class PrivatePortraitProjectUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=128) name: str | None = Field(None, min_length=1, max_length=128, description="项目组名称。")
description: str | None = Field(None, max_length=2000) description: str | None = Field(None, max_length=2000, description="项目组描述。")
status: str | None = None status: str | None = Field(None, description="项目状态。普通前端不建议手动变更,仅管理/排查使用。")
class PrivatePortraitProjectOut(BaseModel): class PrivatePortraitProjectOut(BaseModel):
id: str id: str
user_id: str | None = None user_id: str | None = None
library_type: str = Field(default=PrivatePortraitLibraryType.REAL_PERSON.value)
name: str name: str
name_slug: str | None = None name_slug: str | None = None
remote_project_name: str | None = None remote_project_name: str | None = None
@@ -41,7 +72,11 @@ class PrivatePortraitProjectOut(BaseModel):
status: str status: str
asset_group_count: int = 0 asset_group_count: int = 0
asset_count: int = 0 asset_count: int = 0
image_asset_count: int = 0
video_asset_count: int = 0
active_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 last_used_at: NaiveDatetimeOptional = None
created_at: NaiveDatetimeOptional = None created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None updated_at: NaiveDatetimeOptional = None
@@ -81,18 +116,17 @@ class PrivatePortraitValidateSessionOut(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class PrivatePortraitProjectCreateWithValidateOut(BaseModel): class PrivatePortraitProjectCreateWithValidateOut(BaseModel):
project: PrivatePortraitProjectOut project: PrivatePortraitProjectOut
validate_session: PrivatePortraitValidateSessionOut validate_session: PrivatePortraitValidateSessionOut
poll_interval_ms: int = Field(default=2000, description="PC 端轮询认证状态的建议间隔,单位毫秒") poll_interval_ms: int = Field(default=2000, description="PC 端轮询认证状态的建议间隔,单位毫秒")
class PrivatePortraitAssetGroupOut(BaseModel): class PrivatePortraitAssetGroupOut(BaseModel):
id: str id: str
user_id: str | None = None user_id: str | None = None
project_id: str project_id: str
library_type: str
remote_group_id: str remote_group_id: str
remote_group_name: str | None = None remote_group_name: str | None = None
remote_project_name: str remote_project_name: str
@@ -108,16 +142,22 @@ class PrivatePortraitAssetGroupOut(BaseModel):
class PrivatePortraitAssetCreate(BaseModel): class PrivatePortraitAssetCreate(BaseModel):
url: str = Field(..., min_length=1, description="已上传到本系统且可公网访问的素材 URL") url: str = Field(..., min_length=1, description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换公网地址后调用火山 CreateAsset。")
asset_type: str = Field(default=PrivatePortraitAssetType.IMAGE.value) asset_type: str = Field(default=PrivatePortraitAssetType.IMAGE.value, description="素材类型。当前业务仅开放 Image / VideoAudio 暂不开放。")
name: str | None = Field(None, max_length=256) 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") @field_validator("asset_type")
@classmethod @classmethod
def validate_asset_type(cls, v: str) -> str: def validate_asset_type(cls, v: str) -> str:
value = v or PrivatePortraitAssetType.IMAGE.value value = v or PrivatePortraitAssetType.IMAGE.value
if value not in {item.value for item in PrivatePortraitAssetType}: if value not in {item.value for item in PrivatePortraitAssetType}:
raise ValueError("asset_type 仅支持 Image/Video/Audio") raise ValueError("asset_type 仅支持 Image/VideoAudio 暂未开放")
if value not in PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES:
raise ValueError("Audio 暂未开放,当前仅支持 Image/Video")
return value return value
@@ -127,6 +167,7 @@ class PrivatePortraitAssetOut(BaseModel):
project_id: str project_id: str
project_name: str | None = None project_name: str | None = None
group_id: str group_id: str
library_type: str
remote_group_id: str remote_group_id: str
remote_asset_id: str | None = None remote_asset_id: str | None = None
remote_project_name: str | None = None remote_project_name: str | None = None
@@ -134,10 +175,16 @@ class PrivatePortraitAssetOut(BaseModel):
name: str | None = None name: str | None = None
source_url: str source_url: str
preview_url: str | None = None preview_url: str | None = None
display_url: str | None = None
provider_url: str | None = None
remote_url: str | None = None remote_url: str | None = None
remote_url_expired_at: NaiveDatetimeOptional = 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 status: str
moderation: dict[str, Any] | None = None moderation: Any = None
last_poll_at: NaiveDatetimeOptional = None last_poll_at: NaiveDatetimeOptional = None
next_poll_at: NaiveDatetimeOptional = None next_poll_at: NaiveDatetimeOptional = None
poll_count: int = 0 poll_count: int = 0
@@ -162,10 +209,15 @@ class PrivatePortraitSelectableAssetOut(BaseModel):
id: str id: str
project_id: str project_id: str
project_name: str project_name: str
library_type: str
name: str | None = None name: str | None = None
asset_type: str asset_type: str
preview_url: str | None = None 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 created_at: NaiveDatetimeOptional = None
@@ -178,4 +230,32 @@ class PrivatePortraitSelectableAssetListOut(BaseModel):
class PrivatePortraitDeleteOut(BaseModel): class PrivatePortraitDeleteOut(BaseModel):
success: bool = True 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],
)
+1 -1
View File
@@ -15,7 +15,7 @@ class UserOut(BaseModel):
allowed_menus: list | None = None allowed_menus: list | None = None
must_set_password: bool = False must_set_password: bool = False
resource_capacity: ResourceCapacityUsageOut | None = None resource_capacity: ResourceCapacityUsageOut | None = None
private_portrait_image_limit: int = 5 private_portrait_asset_limit: int = 5
team_id: str | None = None team_id: str | None = None
team_name: str | None = None team_name: str | None = None
is_team_manager: bool = False is_team_manager: bool = False
@@ -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
@@ -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
@@ -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
@@ -51,7 +51,7 @@ def _remote_error_http_status(code: str) -> int:
class ArkPrivateAssetClient: 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): 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 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]: 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}) 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]: 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} payload: dict[str, Any] = {"GroupId": group_id, "URL": url, "AssetType": asset_type, "ProjectName": project_name}
if name: if name:
@@ -6,33 +6,42 @@ from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import and_, func, select, update from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.enums.private_portrait import ( from app.enums.private_portrait import (
PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS, PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS,
PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT, PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT,
PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT, PRIVATE_PORTRAIT_ASSET_URI_PREFIX,
PRIVATE_PORTRAIT_GROUP_TYPE, PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE,
PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE, PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE,
PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES, PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES,
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS,
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT,
PrivatePortraitAssetGroupStatus, PrivatePortraitAssetGroupStatus,
PrivatePortraitAssetStatus, PrivatePortraitAssetStatus,
PrivatePortraitAssetType, PrivatePortraitAssetType,
PrivatePortraitEventSource, PrivatePortraitEventSource,
PrivatePortraitEventStatus, PrivatePortraitEventStatus,
PrivatePortraitEventType, PrivatePortraitEventType,
PrivatePortraitLibraryType,
PrivatePortraitProjectStatus, PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus, PrivatePortraitRemoteDeleteStatus,
PrivatePortraitValidateSessionStatus, PrivatePortraitValidateSessionStatus,
) )
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession
from app.models.user import User from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitConfigOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
from app.services.operation_log_service import log_operation_error, log_operation_event 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.ark_client import ArkPrivateAssetClient
from app.services.private_portrait.project_service import get_user_project, refresh_project_counters 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 from app.utils.id_gen import generate_id
DOMAIN = "private_portrait" DOMAIN = "private_portrait"
@@ -53,7 +62,6 @@ def _loads(data: str | None) -> Any:
return None return None
def _exception_message(exc: Exception) -> str: def _exception_message(exc: Exception) -> str:
if isinstance(exc, HTTPException): if isinstance(exc, HTTPException):
detail = exc.detail detail = exc.detail
@@ -65,7 +73,7 @@ def _exception_message(exc: Exception) -> str:
def _public_url(url: str) -> 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 url
return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}" 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] return f"{user_id}-{safe_name}"[:128]
async def get_user_private_portrait_config(db: AsyncSession, *, user_id: str) -> PrivatePortraitConfigOut: def _asset_display_url(asset: PrivatePortraitAsset) -> str | None:
user = (await db.execute(select(User).where(User.id == user_id).limit(1))).scalar_one_or_none() return asset.preview_url or asset.remote_url or asset.source_url 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)
async def set_user_private_portrait_limit(db: AsyncSession, *, user_id: str, limit: int) -> User: def _provider_url(asset: PrivatePortraitAsset) -> str | None:
user = (await db.execute(select(User).where(User.id == user_id).limit(1))).scalar_one_or_none() return f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}" if asset.remote_asset_id else None
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.private_portrait_image_limit = max(0, int(limit))
await db.flush()
return user
async def count_user_counting_image_assets(db: AsyncSession, *, user_id: str) -> int: def _poll_interval_seconds(asset_type: str) -> int:
statuses = [PrivatePortraitAssetStatus.CREATING.value, PrivatePortraitAssetStatus.PROCESSING.value, PrivatePortraitAssetStatus.ACTIVE.value] if asset_type == PrivatePortraitAssetType.VIDEO.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 PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS
return int(total or 0) return PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS
async def _lock_user_for_upload(db: AsyncSession, *, user_id: str) -> User: def _poll_max_count(asset_type: str) -> int:
# 锁 users 行,避免并发绕过用户总量限制。SQLite 会忽略 FOR UPDATE,不影响本地开发。 if asset_type == PrivatePortraitAssetType.VIDEO.value:
user = (await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))).scalar_one_or_none() return PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT
if not user: return PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT
raise HTTPException(status_code=404, detail="用户不存在")
return user
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/VideoAudio 暂未开放")
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: 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: def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None, include_user: bool = False) -> PrivatePortraitAssetOut:
display_url = _asset_display_url(asset)
return PrivatePortraitAssetOut( return PrivatePortraitAssetOut(
id=asset.id, id=asset.id,
user_id=asset.user_id if include_user else None, user_id=asset.user_id if include_user else None,
project_id=asset.project_id, project_id=asset.project_id,
project_name=project_name, project_name=project_name,
group_id=asset.group_id, group_id=asset.group_id,
library_type=asset.library_type,
remote_group_id=asset.remote_group_id, remote_group_id=asset.remote_group_id,
remote_asset_id=asset.remote_asset_id, remote_asset_id=asset.remote_asset_id,
remote_project_name=asset.remote_project_name, 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, name=asset.name,
source_url=asset.source_url, source_url=asset.source_url,
preview_url=asset.preview_url, preview_url=asset.preview_url,
display_url=display_url,
provider_url=_provider_url(asset),
remote_url=asset.remote_url, remote_url=asset.remote_url,
remote_url_expired_at=asset.remote_url_expired_at, 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, status=asset.status,
moderation=_loads(asset.moderation_json), moderation=_loads(asset.moderation_json),
last_poll_at=asset.last_poll_at, 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 ( return (
await db.execute( await db.execute(
select(PrivatePortraitAssetGroup) select(PrivatePortraitAssetGroup)
.where( .where(*filters)
PrivatePortraitAssetGroup.project_id == project_id,
PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value,
PrivatePortraitAssetGroup.deleted_at.is_(None),
)
.order_by(PrivatePortraitAssetGroup.created_at.desc()) .order_by(PrivatePortraitAssetGroup.created_at.desc())
.limit(1) .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: 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: if active_group or project.status == PrivatePortraitProjectStatus.ACTIVE.value:
raise HTTPException(status_code=409, detail="该真人素材项目已完成认证,不能重复认证") raise HTTPException(status_code=409, detail="该真人素材项目已完成认证,不能重复认证")
@@ -206,12 +222,7 @@ async def _ensure_project_can_validate(db: AsyncSession, *, project: PrivatePort
select(PrivatePortraitValidateSession) select(PrivatePortraitValidateSession)
.where( .where(
PrivatePortraitValidateSession.project_id == project.id, PrivatePortraitValidateSession.project_id == project.id,
PrivatePortraitValidateSession.status.in_( PrivatePortraitValidateSession.status.in_([PrivatePortraitValidateSessionStatus.CREATED.value, PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value]),
[
PrivatePortraitValidateSessionStatus.CREATED.value,
PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value,
]
),
PrivatePortraitValidateSession.expired_at.is_not(None), PrivatePortraitValidateSession.expired_at.is_not(None),
PrivatePortraitValidateSession.expired_at > now, 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: 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) reusable_session = await _ensure_project_can_validate(db, project=project)
if reusable_session: if reusable_session:
return 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.h5_link = resp.get("H5Link") or resp.get("h5Link")
session.raw_response_json = _json(resp) session.raw_response_json = _json(resp)
await db.flush() await db.flush()
# created_at / updated_at 来自数据库默认值或 onupdateflush 后可能处于 expired 状态。
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
await db.refresh(session) 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 return session
except Exception as exc: except Exception as exc:
session.status = PrivatePortraitValidateSessionStatus.FAILED.value 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) 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 raise
async def get_validate_session(db: AsyncSession, *, user_id: str | None, session_id: str) -> PrivatePortraitValidateSession: async def get_validate_session(db: AsyncSession, *, user_id: str | None, session_id: str) -> PrivatePortraitValidateSession:
filters = [PrivatePortraitValidateSession.id == session_id] filters = [PrivatePortraitValidateSession.id == session_id]
if user_id is not None: 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) raise HTTPException(status_code=400, detail=session.error_message)
try: 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: if existing_group:
session.remote_group_id = existing_group.remote_group_id session.remote_group_id = existing_group.remote_group_id
session.status = PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value 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(), id=generate_id(),
user_id=session.user_id, user_id=session.user_id,
project_id=session.project_id, project_id=session.project_id,
library_type=PrivatePortraitLibraryType.REAL_PERSON.value,
remote_group_id=group_id, remote_group_id=group_id,
remote_group_name=remote_group_name, remote_group_name=remote_group_name,
remote_project_name=session.remote_project_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, status=PrivatePortraitAssetGroupStatus.ACTIVE.value,
raw_response_json=_json(resp), 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 refresh_project_counters(db, [session.project_id])
await db.flush() await db.flush()
await db.refresh(session) 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 return session
except Exception as exc: except Exception as exc:
session.status = PrivatePortraitValidateSessionStatus.FAILED.value 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) 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 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: if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="请先完成真人授权认证,再上传素材") detail = "请先完成真人授权认证,再上传素材" if project.library_type == PrivatePortraitLibraryType.REAL_PERSON.value else "虚拟人像素材组尚未创建成功,不能上传素材"
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)) 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() group = result.scalar_one_or_none()
if not group: if not group:
raise HTTPException(status_code=400, detail="请先完成真人授权认证,再上传素材") raise HTTPException(status_code=400, detail="项目没有可用的远程素材")
return group return group
async def create_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate) -> PrivatePortraitAsset: async def create_asset(
project = await get_user_project(db, user_id=user_id, project_id=project_id) 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: if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="项目正在真人认证或认证未通过,不能上传素材") 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} 张图片,请删除已有素材后再上传")
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) public_url = _public_url(payload.url)
asset = PrivatePortraitAsset( asset = PrivatePortraitAsset(
id=generate_id(), id=generate_id(),
user_id=user_id, user_id=user_id,
project_id=project.id, project_id=project.id,
group_id=group.id, group_id=group.id,
library_type=project.library_type,
remote_group_id=group.remote_group_id, remote_group_id=group.remote_group_id,
remote_project_name=project.remote_project_name, remote_project_name=project.remote_project_name,
asset_type=payload.asset_type, asset_type=payload.asset_type,
name=payload.name, name=payload.name,
source_url=public_url, source_url=public_url,
preview_url=payload.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, status=PrivatePortraitAssetStatus.CREATING.value,
) )
db.add(asset) db.add(asset)
await db.flush() 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: 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_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") 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) now = datetime.now(timezone.utc)
asset.remote_asset_id = remote_asset_id asset.remote_asset_id = remote_asset_id
asset.status = PrivatePortraitAssetStatus.PROCESSING.value 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) asset.raw_response_json = _json(remote_resp)
await refresh_project_counters(db, [project.id]) await refresh_project_counters(db, [project.id])
await db.flush() await db.flush()
# created_at / updated_at 来自数据库默认值或 onupdateflush 后可能处于 expired 状态。
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
await db.refresh(asset) 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 return asset
except Exception as exc: except Exception as exc:
asset.status = PrivatePortraitAssetStatus.FAILED.value 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) filters.append(PrivatePortraitAsset.user_id == user_id)
asset = (await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))).scalar_one_or_none() asset = (await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))).scalar_one_or_none()
if not asset: if not asset:
raise HTTPException(status_code=404, detail="真人素材不存在") raise HTTPException(status_code=404, detail="私域人像素材不存在")
if asset.deleted_at is not None: 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: 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 source = PrivatePortraitEventSource.CELERY.value if user_id is None else PrivatePortraitEventSource.API.value
log_operation_event( 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, user_id=asset.user_id,
project_id=asset.project_id, project_id=asset.project_id,
asset_id=asset.id, asset_id=asset.id,
detail={ 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},
"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,
},
) )
try: 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) 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.status = status
asset.remote_url = remote_resp.get("URL") or remote_resp.get("url") or asset.remote_url 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")) 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.status = PrivatePortraitAssetStatus.FAILED.value
asset.error_message = "素材入库轮询超时" asset.error_message = "素材入库轮询超时"
asset.next_poll_at = None asset.next_poll_at = None
log_operation_event( 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)
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,
)
elif asset.status == PrivatePortraitAssetStatus.PROCESSING.value: 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: else:
asset.next_poll_at = None 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 refresh_project_counters(db, [asset.project_id])
await db.flush() await db.flush()
await db.refresh(asset) await db.refresh(asset)
log_operation_event( 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})
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},
)
return asset return asset
except Exception as exc: 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) 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 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 = max(1, page)
page_size = min(max(1, page_size), 100) page_size = min(max(1, page_size), 100)
filters = [PrivatePortraitAsset.deleted_at.is_(None)] 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) filters.append(PrivatePortraitAsset.user_id == user_id)
if project_id: if project_id:
filters.append(PrivatePortraitAsset.project_id == 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: if status:
filters.append(PrivatePortraitAsset.status == status) filters.append(PrivatePortraitAsset.status == status)
if keyword: 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 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]: 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) 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, ""), 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 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: async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str, library_type: str | None = None) -> 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() 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: if not asset:
raise HTTPException(status_code=404, detail="真人素材不存在") raise HTTPException(status_code=404, detail="私域人像素材不存在")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
asset.deleted_at = now asset.deleted_at = now
asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value 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 refresh_project_counters(db, [asset.project_id])
await db.flush() await db.flush()
await db.refresh(asset) 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 return asset
async def delete_asset_remote(db: AsyncSession, *, asset_id: str) -> None: 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() asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id).limit(1))).scalar_one_or_none()
if not asset: if not asset:
log_operation_event( 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="远程删除跳过:本地素材不存在")
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 return
if not asset.remote_asset_id: if not asset.remote_asset_id:
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
asset.remote_delete_error = None asset.remote_delete_error = None
await db.flush() await db.flush()
log_operation_event( 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")
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 return
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
log_operation_event( 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})
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},
)
try: try:
await ArkPrivateAssetClient(for_celery=True).delete_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id) 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.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
asset.remote_deleted_at = now asset.remote_deleted_at = now
asset.remote_delete_error = None asset.remote_delete_error = None
log_operation_event( 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})
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},
)
except Exception as exc: except Exception as exc:
asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.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_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
group.remote_delete_error = None group.remote_delete_error = None
await db.flush() await db.flush()
log_operation_event( 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")
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 return
client = client or ArkPrivateAssetClient(for_celery=True) client = client or ArkPrivateAssetClient(for_celery=True)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
log_operation_event( 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})
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},
)
try: try:
await client.delete_asset_group(project_name=group.remote_project_name, group_id=group.remote_group_id) await client.delete_asset_group(project_name=group.remote_project_name, group_id=group.remote_group_id)
group.status = PrivatePortraitAssetGroupStatus.REMOTE_DELETED.value group.status = PrivatePortraitAssetGroupStatus.REMOTE_DELETED.value
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
group.remote_deleted_at = now group.remote_deleted_at = now
group.remote_delete_error = None group.remote_delete_error = None
log_operation_event( 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})
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},
)
except Exception as exc: except Exception as exc:
group.status = PrivatePortraitAssetGroupStatus.DELETE_FAILED.value group.status = PrivatePortraitAssetGroupStatus.DELETE_FAILED.value
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.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: async def delete_project_remote(db: AsyncSession, *, project_id: str) -> None:
log_operation_event( 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="开始远程删除私域人像素材项目资源")
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)) rows = await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.project_id == project_id))
for asset in rows.scalars().all(): for asset in rows.scalars().all():
await delete_asset_remote(db, asset_id=asset.id) 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) client = ArkPrivateAssetClient(for_celery=True)
for group in groups.scalars().all(): for group in groups.scalars().all():
await _delete_asset_group_remote(db, group=group, client=client) await _delete_asset_group_remote(db, group=group, client=client)
log_operation_event( 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="远程删除私域人像素材项目资源完成")
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() await db.flush()
@@ -707,13 +669,7 @@ async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int:
.limit(limit) .limit(limit)
) )
ids = [row[0] for row in rows.all()] ids = [row[0] for row in rows.all()]
log_operation_event( 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)})
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 success_count = 0
failed_count = 0 failed_count = 0
for asset_id in ids: for asset_id in ids:
@@ -722,38 +678,15 @@ async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int:
success_count += 1 success_count += 1
except Exception as exc: except Exception as exc:
failed_count += 1 failed_count += 1
log_operation_error( log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_POLL_FAILED.value, source=PrivatePortraitEventSource.CELERY.value, asset_id=asset_id, exc=exc)
domain=DOMAIN, 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})
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) return len(ids)
async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[str, int]: async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[str, int]:
log_operation_event( 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})
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] statuses = [PrivatePortraitRemoteDeleteStatus.PENDING.value, PrivatePortraitRemoteDeleteStatus.FAILED.value]
asset_rows = await db.execute( asset_rows = await db.execute(select(PrivatePortraitAsset.id).where(PrivatePortraitAsset.remote_delete_status.in_(statuses)).order_by(PrivatePortraitAsset.updated_at.asc()).limit(limit))
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()] asset_ids = [row[0] for row in asset_rows.all()]
for asset_id in asset_ids: for asset_id in asset_ids:
await delete_asset_remote(db, asset_id=asset_id) 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)) remaining = max(0, limit - len(asset_ids))
group_count = 0 group_count = 0
if remaining > 0: if remaining > 0:
group_rows = await db.execute( group_rows = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.remote_delete_status.in_(statuses)).order_by(PrivatePortraitAssetGroup.updated_at.asc()).limit(remaining))
select(PrivatePortraitAssetGroup)
.where(PrivatePortraitAssetGroup.remote_delete_status.in_(statuses))
.order_by(PrivatePortraitAssetGroup.updated_at.asc())
.limit(remaining)
)
client = ArkPrivateAssetClient(for_celery=True) client = ArkPrivateAssetClient(for_celery=True)
groups = list(group_rows.scalars().all()) groups = list(group_rows.scalars().all())
group_count = len(groups) 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) 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} result = {"asset_count": len(asset_ids), "group_count": group_count, "total_count": len(asset_ids) + group_count}
log_operation_event( log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_DONE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, detail=result)
domain=DOMAIN,
event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_DONE.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.CELERY.value,
detail=result,
)
return result return result
@@ -11,9 +11,11 @@ from app.enums.private_portrait import (
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME, PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
PrivatePortraitAssetGroupStatus, PrivatePortraitAssetGroupStatus,
PrivatePortraitAssetStatus, PrivatePortraitAssetStatus,
PrivatePortraitAssetType,
PrivatePortraitEventSource, PrivatePortraitEventSource,
PrivatePortraitEventStatus, PrivatePortraitEventStatus,
PrivatePortraitEventType, PrivatePortraitEventType,
PrivatePortraitLibraryType,
PrivatePortraitProjectStatus, PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus, PrivatePortraitRemoteDeleteStatus,
) )
@@ -27,16 +29,24 @@ DOMAIN = "private_portrait"
def _safe_slug(value: str, *, max_length: int = 80) -> str: def _safe_slug(value: str, *, max_length: int = 80) -> str:
value = (value or "").strip().lower() value = (value or "").strip().lower()
# 先保留常见英文数字连字符;中文等字符统一转 _,仅用于本地项目 slug。
value = re.sub(r"[^a-z0-9_-]+", "_", value) value = re.sub(r"[^a-z0-9_-]+", "_", value)
value = re.sub(r"_+", "_", value).strip("_-") value = re.sub(r"_+", "_", value).strip("_-")
return (value[:max_length] or "project") 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: def project_to_out(project: PrivatePortraitProject, *, include_user: bool = False) -> PrivatePortraitProjectOut:
return PrivatePortraitProjectOut( return PrivatePortraitProjectOut(
id=project.id, id=project.id,
user_id=project.user_id if include_user else None, user_id=project.user_id if include_user else None,
library_type=project.library_type,
name=project.name, name=project.name,
name_slug=project.name_slug, name_slug=project.name_slug,
remote_project_name=project.remote_project_name, remote_project_name=project.remote_project_name,
@@ -44,37 +54,57 @@ def project_to_out(project: PrivatePortraitProject, *, include_user: bool = Fals
status=project.status, status=project.status,
asset_group_count=project.asset_group_count or 0, asset_group_count=project.asset_group_count or 0,
asset_count=project.asset_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_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, last_used_at=project.last_used_at,
created_at=project.created_at, created_at=project.created_at,
updated_at=project.updated_at, updated_at=project.updated_at,
) )
async def get_user_project(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitProject: async def get_user_project(
result = await db.execute( db: AsyncSession,
select(PrivatePortraitProject).where( *,
PrivatePortraitProject.id == project_id, user_id: str,
PrivatePortraitProject.user_id == user_id, project_id: str,
PrivatePortraitProject.deleted_at.is_(None), library_type: str | None = None,
).limit(1) ) -> 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() project = result.scalar_one_or_none()
if not project: if not project:
raise HTTPException(status_code=404, detail="真人素材项目不存在") raise HTTPException(status_code=404, detail="私域人像素材项目不存在")
return project 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) slug = _safe_slug(payload.name)
project = PrivatePortraitProject( project = PrivatePortraitProject(
id=generate_id(), id=generate_id(),
user_id=user_id, user_id=user_id,
library_type=library_type,
name=payload.name.strip(), name=payload.name.strip(),
name_slug=slug, name_slug=slug,
remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME, remote_project_name=remote_project_name,
description=payload.description, description=payload.description,
status=PrivatePortraitProjectStatus.VALIDATING.value, status=status or _status_for_created_project(library_type),
) )
db.add(project) db.add(project)
await db.flush() await db.flush()
@@ -85,35 +115,38 @@ async def create_project(db: AsyncSession, *, user_id: str, payload: PrivatePort
source=PrivatePortraitEventSource.API.value, source=PrivatePortraitEventSource.API.value,
user_id=user_id, user_id=user_id,
project_id=project.id, project_id=project.id,
message="创建待认证真人素材项目", message="创建私域人像素材项目",
detail={"name": project.name, "remote_project_name": project.remote_project_name, "status": project.status}, detail={"name": project.name, "library_type": library_type, "remote_project_name": project.remote_project_name, "status": project.status},
) )
return project return project
async def update_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate) -> PrivatePortraitProject: async def update_project(
project = await get_user_project(db, user_id=user_id, project_id=project_id) 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 = { before = {
"name": project.name, "name": project.name,
"name_slug": project.name_slug, "name_slug": project.name_slug,
"remote_project_name": project.remote_project_name, "remote_project_name": project.remote_project_name,
"description": project.description, "description": project.description,
"status": project.status, "status": project.status,
"library_type": project.library_type,
} }
if payload.name is not None: if payload.name is not None:
new_name = payload.name.strip() new_name = payload.name.strip()
if new_name and new_name != project.name: if new_name and new_name != project.name:
project.name = new_name project.name = new_name
project.name_slug = _safe_slug(new_name) project.name_slug = _safe_slug(new_name)
project.remote_project_name = PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME
if payload.description is not None: if payload.description is not None:
project.description = payload.description project.description = payload.description
if payload.status is not None: if payload.status is not None:
allowed_statuses = { allowed_statuses = {item.value for item in PrivatePortraitProjectStatus if item != PrivatePortraitProjectStatus.DELETED}
PrivatePortraitProjectStatus.VALIDATING.value,
PrivatePortraitProjectStatus.ACTIVE.value,
PrivatePortraitProjectStatus.VALIDATE_FAILED.value,
}
if payload.status not in allowed_statuses: if payload.status not in allowed_statuses:
raise HTTPException(status_code=400, detail="项目状态不支持") raise HTTPException(status_code=400, detail="项目状态不支持")
project.status = payload.status 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, "remote_project_name": project.remote_project_name,
"description": project.description, "description": project.description,
"status": project.status, "status": project.status,
"library_type": project.library_type,
} }
log_operation_event( log_operation_event(
domain=DOMAIN, domain=DOMAIN,
@@ -132,7 +166,7 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay
source=PrivatePortraitEventSource.API.value, source=PrivatePortraitEventSource.API.value,
user_id=user_id, user_id=user_id,
project_id=project.id, project_id=project.id,
message="更新真人素材项目", message="更新私域人像素材项目",
detail={"before": before, "after": after}, detail={"before": before, "after": after},
) )
return project return project
@@ -146,18 +180,27 @@ async def list_projects(
page_size: int = 20, page_size: int = 20,
keyword: str | None = None, keyword: str | None = None,
status: str | None = None, status: str | None = None,
library_type: str | None = None,
) -> tuple[list[PrivatePortraitProject], int]: ) -> tuple[list[PrivatePortraitProject], int]:
page = max(1, page) page = max(1, page)
page_size = min(max(1, page_size), 100) page_size = min(max(1, page_size), 100)
filters = [PrivatePortraitProject.deleted_at.is_(None)] filters = [PrivatePortraitProject.deleted_at.is_(None)]
if user_id: if user_id:
filters.append(PrivatePortraitProject.user_id == user_id) filters.append(PrivatePortraitProject.user_id == user_id)
if library_type:
filters.append(PrivatePortraitProject.library_type == library_type)
if keyword: if keyword:
filters.append(PrivatePortraitProject.name.ilike(f"%{keyword.strip()}%")) filters.append(PrivatePortraitProject.name.ilike(f"%{keyword.strip()}%"))
if status: if status:
filters.append(PrivatePortraitProject.status == status) filters.append(PrivatePortraitProject.status == status)
total = (await db.execute(select(func.count(PrivatePortraitProject.id)).where(*filters))).scalar_one() 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) 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( select(
PrivatePortraitAsset.project_id, PrivatePortraitAsset.project_id,
func.count(PrivatePortraitAsset.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, 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)) .where(PrivatePortraitAsset.project_id.in_(project_ids), PrivatePortraitAsset.deleted_at.is_(None))
.group_by(PrivatePortraitAsset.project_id) .group_by(PrivatePortraitAsset.project_id)
) )
group_count_map = {pid: int(count or 0) for pid, count in group_rows.all()} group_count_map = {pid: int(count or 0) for pid, count in group_rows.all()}
asset_count_map: dict[str, tuple[int, int]] = {} asset_count_map: dict[str, tuple[int, int, int, int, int, int]] = {}
for pid, total, active_total in asset_rows.all(): 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(active_total or 0)) 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: for pid in project_ids:
total, active_total = asset_count_map.get(pid, (0, 0)) 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, active_asset_count=active_total)) 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: async def soft_delete_project(
project = await get_user_project(db, user_id=user_id, project_id=project_id) 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) now = datetime.now(timezone.utc)
project.deleted_at = now project.deleted_at = now
project.status = PrivatePortraitProjectStatus.DELETED.value 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(
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)) 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() 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 return project
@@ -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
@@ -0,0 +1 @@
from app.services.private_portrait.real_person.service import *
@@ -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)
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.private_portrait import ( from app.enums.private_portrait import (
PRIVATE_PORTRAIT_ASSET_URI_PREFIX, PRIVATE_PORTRAIT_ASSET_URI_PREFIX,
PrivatePortraitAssetStatus, PrivatePortraitAssetStatus,
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
PrivatePortraitAssetType, PrivatePortraitAssetType,
PrivatePortraitEventSource, PrivatePortraitEventSource,
PrivatePortraitEventStatus, PrivatePortraitEventStatus,
@@ -25,7 +26,6 @@ DOMAIN = "private_portrait"
_ASSET_TYPE_TO_REFERENCE_TYPE = { _ASSET_TYPE_TO_REFERENCE_TYPE = {
PrivatePortraitAssetType.IMAGE.value: "image", PrivatePortraitAssetType.IMAGE.value: "image",
PrivatePortraitAssetType.VIDEO.value: "video", 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 "") asset_id = str(_ref_get(ref, "private_asset_id") or "")
if not asset_id: 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) asset = asset_map.get(asset_id)
if not asset: if not asset:
raise HTTPException(status_code=400, detail="真人素材不存在") raise HTTPException(status_code=400, detail="私域人像素材不存在")
if asset.user_id != user_id: 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: 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: 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: 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) expected_ref_type = _ASSET_TYPE_TO_REFERENCE_TYPE.get(asset.asset_type)
ref_type = _normalize_ref_type(_ref_get(ref, "type")) ref_type = _normalize_ref_type(_ref_get(ref, "type"))
if expected_ref_type and ref_type and ref_type != expected_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}" provider_url = f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}"
_ref_set(ref, "source", PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value) _ref_set(ref, "source", PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value)
@@ -0,0 +1 @@
from app.services.private_portrait.virtual.service import *
@@ -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)
+8 -3
View File
@@ -257,9 +257,14 @@ export interface AdminNotification {
export interface PrivatePortraitConfig { export interface PrivatePortraitConfig {
enabled: boolean; enabled: boolean;
imageLimit: number; assetLimit: number;
usedImageCount: number; usedAssetCount: number;
remainingImageCount: number; remainingAssetCount: number;
supportedAssetTypes?: string[];
unsupportedAssetTypes?: string[];
imageLimit?: number;
usedImageCount?: number;
remainingImageCount?: number;
} }
export interface PrivatePortraitProject { export interface PrivatePortraitProject {